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/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 movies..."
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 movies and videos..."
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 for movies and videos"
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
  */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"}
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
- // Limit placeholder length to prevent layout issues
45
- view.placeholder = String(placeholder.prefix(Self.maxStringLength))
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 = String(text.prefix(Self.maxStringLength))
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 = String(text.prefix(Self.maxStringLength))
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 = String(text.prefix(Self.maxStringLength))
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 = String(text.prefix(Self.maxStringLength))
135
+ view.noResultsHintText = Self.truncateString(text, propName: "noResultsHintText", view: view)
112
136
  }
113
137
 
114
138
  Prop("textColor") { (view: ExpoTvosSearchView, colorHex: String?) in