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
package/build/index.d.ts
CHANGED
|
@@ -45,13 +45,21 @@ export interface SearchViewErrorEvent {
|
|
|
45
45
|
export interface ValidationWarningEvent {
|
|
46
46
|
nativeEvent: {
|
|
47
47
|
/** Type of validation warning */
|
|
48
|
-
type: "field_truncated" | "value_clamped" | "url_invalid" | "validation_failed";
|
|
48
|
+
type: "field_truncated" | "value_clamped" | "value_truncated" | "results_truncated" | "url_invalid" | "url_insecure" | "validation_failed";
|
|
49
49
|
/** Human-readable warning message */
|
|
50
50
|
message: string;
|
|
51
51
|
/** Optional additional context */
|
|
52
52
|
context?: string;
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Event payload for search field focus changes.
|
|
57
|
+
* Fired when the native search field gains or loses focus.
|
|
58
|
+
* Useful for managing RN gesture handlers via TVEventControl.
|
|
59
|
+
*/
|
|
60
|
+
export interface SearchFieldFocusEvent {
|
|
61
|
+
nativeEvent: Record<string, never>;
|
|
62
|
+
}
|
|
55
63
|
/**
|
|
56
64
|
* Represents a single search result displayed in the grid.
|
|
57
65
|
*/
|
|
@@ -62,7 +70,7 @@ export interface SearchResult {
|
|
|
62
70
|
title: string;
|
|
63
71
|
/** Optional secondary text displayed below the title */
|
|
64
72
|
subtitle?: string;
|
|
65
|
-
/** Optional image URL for the result poster/thumbnail */
|
|
73
|
+
/** Optional image URL for the result poster/thumbnail. Supports HTTPS, HTTP, and data: URIs */
|
|
66
74
|
imageUrl?: string;
|
|
67
75
|
}
|
|
68
76
|
/**
|
|
@@ -73,7 +81,7 @@ export interface SearchResult {
|
|
|
73
81
|
* <TvosSearchView
|
|
74
82
|
* results={searchResults}
|
|
75
83
|
* columns={5}
|
|
76
|
-
* placeholder="Search
|
|
84
|
+
* placeholder="Search..."
|
|
77
85
|
* isLoading={loading}
|
|
78
86
|
* topInset={140}
|
|
79
87
|
* onSearch={(e) => handleSearch(e.nativeEvent.query)}
|
|
@@ -101,9 +109,20 @@ export interface TvosSearchViewProps {
|
|
|
101
109
|
columns?: number;
|
|
102
110
|
/**
|
|
103
111
|
* Placeholder text shown in the search field when empty.
|
|
104
|
-
* @default "Search
|
|
112
|
+
* @default "Search..."
|
|
105
113
|
*/
|
|
106
114
|
placeholder?: string;
|
|
115
|
+
/**
|
|
116
|
+
* Programmatically set the search field text.
|
|
117
|
+
* Works like React Native TextInput's `value` + `onChangeText` pattern.
|
|
118
|
+
* Useful for restoring search state, deep links, or "search for similar" flows.
|
|
119
|
+
*
|
|
120
|
+
* **Warning:** Avoid setting `searchText` inside your `onSearch` handler with
|
|
121
|
+
* transforms (e.g., trimming, lowercasing). The native guard only prevents
|
|
122
|
+
* same-value loops — transformed values will trigger a new `onSearch` event,
|
|
123
|
+
* creating an infinite update cycle.
|
|
124
|
+
*/
|
|
125
|
+
searchText?: string;
|
|
107
126
|
/**
|
|
108
127
|
* Whether to show a loading indicator.
|
|
109
128
|
* @default false
|
|
@@ -155,7 +174,7 @@ export interface TvosSearchViewProps {
|
|
|
155
174
|
marqueeDelay?: number;
|
|
156
175
|
/**
|
|
157
176
|
* Text displayed when the search field is empty and no results are shown.
|
|
158
|
-
* @default "Search
|
|
177
|
+
* @default "Search your library"
|
|
159
178
|
*/
|
|
160
179
|
emptyStateText?: string;
|
|
161
180
|
/**
|
|
@@ -231,6 +250,9 @@ export interface TvosSearchViewProps {
|
|
|
231
250
|
/**
|
|
232
251
|
* Callback fired when the search text changes.
|
|
233
252
|
* Debounce this handler to avoid excessive API calls.
|
|
253
|
+
*
|
|
254
|
+
* **Note:** If using the `searchText` prop, do not set it to a transformed
|
|
255
|
+
* value inside this handler — see `searchText` docs for loop prevention.
|
|
234
256
|
*/
|
|
235
257
|
onSearch: (event: SearchEvent) => void;
|
|
236
258
|
/**
|
|
@@ -262,6 +284,36 @@ export interface TvosSearchViewProps {
|
|
|
262
284
|
* ```
|
|
263
285
|
*/
|
|
264
286
|
onValidationWarning?: (event: ValidationWarningEvent) => void;
|
|
287
|
+
/**
|
|
288
|
+
* Optional callback fired when the native search field gains focus.
|
|
289
|
+
* Use this to disable RN gesture handlers via TVEventControl if the
|
|
290
|
+
* automatic gesture handling doesn't work on your device.
|
|
291
|
+
*
|
|
292
|
+
* @example
|
|
293
|
+
* ```tsx
|
|
294
|
+
* import { TVEventControl } from 'react-native';
|
|
295
|
+
*
|
|
296
|
+
* onSearchFieldFocused={() => {
|
|
297
|
+
* TVEventControl.disableGestureHandlersCancelTouches();
|
|
298
|
+
* }}
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
onSearchFieldFocused?: (event: SearchFieldFocusEvent) => void;
|
|
302
|
+
/**
|
|
303
|
+
* Optional callback fired when the native search field loses focus.
|
|
304
|
+
* Use this to re-enable RN gesture handlers via TVEventControl if you
|
|
305
|
+
* disabled them in onSearchFieldFocused.
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```tsx
|
|
309
|
+
* import { TVEventControl } from 'react-native';
|
|
310
|
+
*
|
|
311
|
+
* onSearchFieldBlurred={() => {
|
|
312
|
+
* TVEventControl.enableGestureHandlersCancelTouches();
|
|
313
|
+
* }}
|
|
314
|
+
* ```
|
|
315
|
+
*/
|
|
316
|
+
onSearchFieldBlurred?: (event: SearchFieldFocusEvent) => void;
|
|
265
317
|
/**
|
|
266
318
|
* Optional style for the view container.
|
|
267
319
|
*/
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":";AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE;QACX,0DAA0D;QAC1D,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE;QACX,0DAA0D;QAC1D,EAAE,EAAE,MAAM,CAAC;KACZ,CAAC;CACH;AAED;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAC/B,oBAAoB,GACpB,mBAAmB,GACnB,mBAAmB,GACnB,SAAS,CAAC;AAEd;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE;QACX,sDAAsD;QACtD,QAAQ,EAAE,uBAAuB,CAAC;QAClC,mCAAmC;QACnC,OAAO,EAAE,MAAM,CAAC;QAChB,yDAAyD;QACzD,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;CACH;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE;QACX,iCAAiC;QACjC,IAAI,EAAE,iBAAiB,GAAG,eAAe,GAAG,aAAa,GAAG,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#if os(tvOS)
|
|
2
|
+
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
/// Thread-safe NSCache-backed image cache singleton.
|
|
6
|
+
/// Auto-evicts under memory pressure via NSCache's built-in LRU behavior.
|
|
7
|
+
final class ImageCache {
|
|
8
|
+
static let shared = ImageCache()
|
|
9
|
+
|
|
10
|
+
private let cache = NSCache<NSURL, UIImage>()
|
|
11
|
+
|
|
12
|
+
private init() {
|
|
13
|
+
cache.countLimit = 100
|
|
14
|
+
cache.totalCostLimit = 100 * 1024 * 1024 // 100 MB
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func image(for url: URL) -> UIImage? {
|
|
18
|
+
cache.object(forKey: url as NSURL)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func setImage(_ image: UIImage, for url: URL) {
|
|
22
|
+
let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0
|
|
23
|
+
cache.setObject(image, forKey: url as NSURL, cost: cost)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// SwiftUI view that loads images with NSCache backing.
|
|
28
|
+
/// Cached images appear instantly (no loading flash).
|
|
29
|
+
/// On failure, renders EmptyView so the parent placeholder shows through.
|
|
30
|
+
struct CachedAsyncImage: View {
|
|
31
|
+
let url: URL
|
|
32
|
+
let contentMode: ContentMode
|
|
33
|
+
let width: CGFloat
|
|
34
|
+
let height: CGFloat
|
|
35
|
+
|
|
36
|
+
@State private var uiImage: UIImage?
|
|
37
|
+
@State private var isLoading = true
|
|
38
|
+
|
|
39
|
+
var body: some View {
|
|
40
|
+
Group {
|
|
41
|
+
if let uiImage = uiImage {
|
|
42
|
+
Image(uiImage: uiImage)
|
|
43
|
+
.resizable()
|
|
44
|
+
.aspectRatio(contentMode: contentMode)
|
|
45
|
+
.frame(width: width, height: height)
|
|
46
|
+
} else if isLoading {
|
|
47
|
+
ProgressView()
|
|
48
|
+
} else {
|
|
49
|
+
// Failure — show nothing, parent placeholder shows through
|
|
50
|
+
EmptyView()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
.frame(width: width, height: height)
|
|
54
|
+
.task(id: url) {
|
|
55
|
+
uiImage = nil
|
|
56
|
+
isLoading = true
|
|
57
|
+
|
|
58
|
+
if let cached = ImageCache.shared.image(for: url) {
|
|
59
|
+
uiImage = cached
|
|
60
|
+
isLoading = false
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
do {
|
|
65
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
66
|
+
if let image = UIImage(data: data) {
|
|
67
|
+
ImageCache.shared.setImage(image, for: url)
|
|
68
|
+
uiImage = image
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Cancelled or network error — placeholder shows through
|
|
72
|
+
}
|
|
73
|
+
isLoading = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#endif
|
|
@@ -8,11 +8,28 @@ public class ExpoTvosSearchModule: Module {
|
|
|
8
8
|
private static let maxMarqueeDelay: Double = 60.0
|
|
9
9
|
private static let maxStringLength = 500
|
|
10
10
|
|
|
11
|
+
/// Truncates a string to maxStringLength and emits a validation warning if truncation occurred.
|
|
12
|
+
private static func truncateString(
|
|
13
|
+
_ value: String,
|
|
14
|
+
propName: String,
|
|
15
|
+
view: ExpoTvosSearchView
|
|
16
|
+
) -> String {
|
|
17
|
+
let truncated = String(value.prefix(maxStringLength))
|
|
18
|
+
if truncated.count < value.count {
|
|
19
|
+
view.onValidationWarning([
|
|
20
|
+
"type": "value_truncated",
|
|
21
|
+
"message": "\(propName) truncated to \(maxStringLength) characters",
|
|
22
|
+
"context": "original length: \(value.count)"
|
|
23
|
+
])
|
|
24
|
+
}
|
|
25
|
+
return truncated
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
public func definition() -> ModuleDefinition {
|
|
12
29
|
Name("ExpoTvosSearch")
|
|
13
30
|
|
|
14
31
|
View(ExpoTvosSearchView.self) {
|
|
15
|
-
Events("onSearch", "onSelectItem", "onError", "onValidationWarning")
|
|
32
|
+
Events("onSearch", "onSelectItem", "onError", "onValidationWarning", "onSearchFieldFocused", "onSearchFieldBlurred")
|
|
16
33
|
|
|
17
34
|
Prop("results") { (view: ExpoTvosSearchView, results: [[String: Any]]) in
|
|
18
35
|
// Limit results array size to prevent memory issues
|
|
@@ -41,8 +58,15 @@ public class ExpoTvosSearchModule: Module {
|
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
Prop("placeholder") { (view: ExpoTvosSearchView, placeholder: String) in
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
view.placeholder = Self.truncateString(placeholder, propName: "placeholder", view: view)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Prop("searchText") { (view: ExpoTvosSearchView, text: String?) in
|
|
65
|
+
if let text = text {
|
|
66
|
+
view.searchTextProp = Self.truncateString(text, propName: "searchText", view: view)
|
|
67
|
+
} else {
|
|
68
|
+
view.searchTextProp = nil
|
|
69
|
+
}
|
|
46
70
|
}
|
|
47
71
|
|
|
48
72
|
Prop("isLoading") { (view: ExpoTvosSearchView, isLoading: Bool) in
|
|
@@ -96,19 +120,19 @@ public class ExpoTvosSearchModule: Module {
|
|
|
96
120
|
}
|
|
97
121
|
|
|
98
122
|
Prop("emptyStateText") { (view: ExpoTvosSearchView, text: String) in
|
|
99
|
-
view.emptyStateText =
|
|
123
|
+
view.emptyStateText = Self.truncateString(text, propName: "emptyStateText", view: view)
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
Prop("searchingText") { (view: ExpoTvosSearchView, text: String) in
|
|
103
|
-
view.searchingText =
|
|
127
|
+
view.searchingText = Self.truncateString(text, propName: "searchingText", view: view)
|
|
104
128
|
}
|
|
105
129
|
|
|
106
130
|
Prop("noResultsText") { (view: ExpoTvosSearchView, text: String) in
|
|
107
|
-
view.noResultsText =
|
|
131
|
+
view.noResultsText = Self.truncateString(text, propName: "noResultsText", view: view)
|
|
108
132
|
}
|
|
109
133
|
|
|
110
134
|
Prop("noResultsHintText") { (view: ExpoTvosSearchView, text: String) in
|
|
111
|
-
view.noResultsHintText =
|
|
135
|
+
view.noResultsHintText = Self.truncateString(text, propName: "noResultsHintText", view: view)
|
|
112
136
|
}
|
|
113
137
|
|
|
114
138
|
Prop("textColor") { (view: ExpoTvosSearchView, colorHex: String?) in
|