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.
@@ -95,6 +95,109 @@ describe('onSelectItem event structure', () => {
95
95
  });
96
96
  });
97
97
 
98
+ describe('onSearchFieldFocused event structure', () => {
99
+ it('provides empty nativeEvent object', () => {
100
+ const mockHandler = jest.fn();
101
+ const event = { nativeEvent: {} };
102
+
103
+ mockHandler(event);
104
+
105
+ expect(mockHandler).toHaveBeenCalledWith({
106
+ nativeEvent: {},
107
+ });
108
+ expect(mockHandler.mock.calls[0][0].nativeEvent).toEqual({});
109
+ });
110
+
111
+ it('fires when search field gains focus', () => {
112
+ const onSearchFieldFocused = jest.fn();
113
+
114
+ // Native module fires focus event
115
+ onSearchFieldFocused({ nativeEvent: {} });
116
+
117
+ expect(onSearchFieldFocused).toHaveBeenCalledTimes(1);
118
+ expect(onSearchFieldFocused).toHaveBeenCalledWith({ nativeEvent: {} });
119
+ });
120
+ });
121
+
122
+ describe('onSearchFieldBlurred event structure', () => {
123
+ it('provides empty nativeEvent object', () => {
124
+ const mockHandler = jest.fn();
125
+ const event = { nativeEvent: {} };
126
+
127
+ mockHandler(event);
128
+
129
+ expect(mockHandler).toHaveBeenCalledWith({
130
+ nativeEvent: {},
131
+ });
132
+ expect(mockHandler.mock.calls[0][0].nativeEvent).toEqual({});
133
+ });
134
+
135
+ it('fires when search field loses focus', () => {
136
+ const onSearchFieldBlurred = jest.fn();
137
+
138
+ // Native module fires blur event
139
+ onSearchFieldBlurred({ nativeEvent: {} });
140
+
141
+ expect(onSearchFieldBlurred).toHaveBeenCalledTimes(1);
142
+ expect(onSearchFieldBlurred).toHaveBeenCalledWith({ nativeEvent: {} });
143
+ });
144
+ });
145
+
146
+ describe('search field focus integration', () => {
147
+ it('simulates focus/blur cycle for Apple TV keyboard input', () => {
148
+ const onSearchFieldFocused = jest.fn();
149
+ const onSearchFieldBlurred = jest.fn();
150
+ const onSearch = jest.fn();
151
+
152
+ // User focuses search field (keyboard appears)
153
+ onSearchFieldFocused({ nativeEvent: {} });
154
+ expect(onSearchFieldFocused).toHaveBeenCalledTimes(1);
155
+
156
+ // User types in search field
157
+ onSearch({ nativeEvent: { query: 'm' } });
158
+ onSearch({ nativeEvent: { query: 'ma' } });
159
+ onSearch({ nativeEvent: { query: 'mat' } });
160
+ expect(onSearch).toHaveBeenCalledTimes(3);
161
+
162
+ // User exits search field (keyboard dismissed)
163
+ onSearchFieldBlurred({ nativeEvent: {} });
164
+ expect(onSearchFieldBlurred).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ it('handles multiple focus/blur cycles', () => {
168
+ const onSearchFieldFocused = jest.fn();
169
+ const onSearchFieldBlurred = jest.fn();
170
+
171
+ // First focus/blur cycle
172
+ onSearchFieldFocused({ nativeEvent: {} });
173
+ onSearchFieldBlurred({ nativeEvent: {} });
174
+
175
+ // Second focus/blur cycle
176
+ onSearchFieldFocused({ nativeEvent: {} });
177
+ onSearchFieldBlurred({ nativeEvent: {} });
178
+
179
+ // Third focus/blur cycle
180
+ onSearchFieldFocused({ nativeEvent: {} });
181
+ onSearchFieldBlurred({ nativeEvent: {} });
182
+
183
+ expect(onSearchFieldFocused).toHaveBeenCalledTimes(3);
184
+ expect(onSearchFieldBlurred).toHaveBeenCalledTimes(3);
185
+ });
186
+
187
+ it('does not require focus handlers to be provided', () => {
188
+ // These callbacks are optional - ensure no errors when undefined
189
+ const onSearch = jest.fn();
190
+ const onSelectItem = jest.fn();
191
+
192
+ // Simulate usage without focus callbacks
193
+ onSearch({ nativeEvent: { query: 'test' } });
194
+ onSelectItem({ nativeEvent: { id: 'item-1' } });
195
+
196
+ expect(onSearch).toHaveBeenCalledTimes(1);
197
+ expect(onSelectItem).toHaveBeenCalledTimes(1);
198
+ });
199
+ });
200
+
98
201
  describe('event handler integration', () => {
99
202
  it('simulates full search flow', () => {
100
203
  const onSearch = jest.fn();
@@ -123,4 +226,29 @@ describe('event handler integration', () => {
123
226
  expect(onSearch).toHaveBeenCalledTimes(2);
124
227
  expect(onSearch.mock.calls[1][0].nativeEvent.query).toBe('');
125
228
  });
229
+
230
+ it('simulates full Apple TV search flow with focus handling', () => {
231
+ const onSearchFieldFocused = jest.fn();
232
+ const onSearchFieldBlurred = jest.fn();
233
+ const onSearch = jest.fn();
234
+ const onSelectItem = jest.fn();
235
+
236
+ // User navigates to search and focuses the field
237
+ onSearchFieldFocused({ nativeEvent: {} });
238
+
239
+ // User types search query using Siri Remote keyboard
240
+ onSearch({ nativeEvent: { query: 'matrix' } });
241
+ onSearch({ nativeEvent: { query: 'matrix reloaded' } });
242
+
243
+ // User exits keyboard to browse results
244
+ onSearchFieldBlurred({ nativeEvent: {} });
245
+
246
+ // User selects a result
247
+ onSelectItem({ nativeEvent: { id: 'movie-456' } });
248
+
249
+ expect(onSearchFieldFocused).toHaveBeenCalledTimes(1);
250
+ expect(onSearchFieldBlurred).toHaveBeenCalledTimes(1);
251
+ expect(onSearch).toHaveBeenCalledTimes(2);
252
+ expect(onSelectItem).toHaveBeenCalledTimes(1);
253
+ });
126
254
  });
@@ -104,7 +104,7 @@ describe('TvosSearchViewProps defaults', () => {
104
104
  // The actual defaults are applied in Swift (ExpoTvosSearchView.swift)
105
105
  const expectedDefaults = {
106
106
  columns: 5,
107
- placeholder: 'Search movies and videos...', // Matches Swift default
107
+ placeholder: 'Search...', // Matches Swift default
108
108
  isLoading: false,
109
109
  showTitle: false,
110
110
  showSubtitle: false,
@@ -542,11 +542,14 @@ describe('TvosSearchView with native module available', () => {
542
542
  const { TvosSearchView } = require('../index');
543
543
 
544
544
  const mockOnValidationWarning = jest.fn();
545
+ const mockOnSearchFieldFocused = jest.fn();
546
+ const mockOnSearchFieldBlurred = jest.fn();
545
547
 
546
548
  const result = TvosSearchView({
547
549
  results: [{ id: 'test', title: 'Test', subtitle: 'Sub', imageUrl: 'http://example.com/img.jpg' }],
548
550
  columns: 5,
549
551
  placeholder: 'Custom placeholder',
552
+ searchText: 'initial query',
550
553
  isLoading: false,
551
554
  showTitle: true,
552
555
  showSubtitle: true,
@@ -571,9 +574,52 @@ describe('TvosSearchView with native module available', () => {
571
574
  onSelectItem: jest.fn(),
572
575
  onError: jest.fn(),
573
576
  onValidationWarning: mockOnValidationWarning,
577
+ onSearchFieldFocused: mockOnSearchFieldFocused,
578
+ onSearchFieldBlurred: mockOnSearchFieldBlurred,
574
579
  style: { flex: 1 },
575
580
  });
576
581
 
577
582
  expect(result).not.toBeNull();
578
583
  });
584
+
585
+ it('renders without optional focus callbacks', () => {
586
+ const { TvosSearchView } = require('../index');
587
+
588
+ // onSearchFieldFocused and onSearchFieldBlurred are optional
589
+ const result = TvosSearchView({
590
+ results: [],
591
+ onSearch: jest.fn(),
592
+ onSelectItem: jest.fn(),
593
+ // Note: onSearchFieldFocused and onSearchFieldBlurred are not provided
594
+ });
595
+
596
+ expect(result).not.toBeNull();
597
+ });
598
+
599
+ it('accepts searchText prop', () => {
600
+ const { TvosSearchView } = require('../index');
601
+
602
+ const result = TvosSearchView({
603
+ results: [],
604
+ onSearch: jest.fn(),
605
+ onSelectItem: jest.fn(),
606
+ searchText: 'test query',
607
+ });
608
+
609
+ expect(result).not.toBeNull();
610
+ });
611
+
612
+ it('accepts undefined searchText prop', () => {
613
+ const { TvosSearchView } = require('../index');
614
+
615
+ const result = TvosSearchView({
616
+ results: [],
617
+ onSearch: jest.fn(),
618
+ onSelectItem: jest.fn(),
619
+ searchText: undefined,
620
+ });
621
+
622
+ expect(result).not.toBeNull();
623
+ });
579
624
  });
625
+
package/src/index.tsx CHANGED
@@ -55,7 +55,7 @@ export interface SearchViewErrorEvent {
55
55
  export interface ValidationWarningEvent {
56
56
  nativeEvent: {
57
57
  /** Type of validation warning */
58
- type: "field_truncated" | "value_clamped" | "url_invalid" | "validation_failed";
58
+ type: "field_truncated" | "value_clamped" | "value_truncated" | "results_truncated" | "url_invalid" | "url_insecure" | "validation_failed";
59
59
  /** Human-readable warning message */
60
60
  message: string;
61
61
  /** Optional additional context */
@@ -63,6 +63,15 @@ export interface ValidationWarningEvent {
63
63
  };
64
64
  }
65
65
 
66
+ /**
67
+ * Event payload for search field focus changes.
68
+ * Fired when the native search field gains or loses focus.
69
+ * Useful for managing RN gesture handlers via TVEventControl.
70
+ */
71
+ export interface SearchFieldFocusEvent {
72
+ nativeEvent: Record<string, never>;
73
+ }
74
+
66
75
  /**
67
76
  * Represents a single search result displayed in the grid.
68
77
  */
@@ -73,7 +82,7 @@ export interface SearchResult {
73
82
  title: string;
74
83
  /** Optional secondary text displayed below the title */
75
84
  subtitle?: string;
76
- /** Optional image URL for the result poster/thumbnail */
85
+ /** Optional image URL for the result poster/thumbnail. Supports HTTPS, HTTP, and data: URIs */
77
86
  imageUrl?: string;
78
87
  }
79
88
 
@@ -85,7 +94,7 @@ export interface SearchResult {
85
94
  * <TvosSearchView
86
95
  * results={searchResults}
87
96
  * columns={5}
88
- * placeholder="Search movies..."
97
+ * placeholder="Search..."
89
98
  * isLoading={loading}
90
99
  * topInset={140}
91
100
  * onSearch={(e) => handleSearch(e.nativeEvent.query)}
@@ -115,10 +124,22 @@ export interface TvosSearchViewProps {
115
124
 
116
125
  /**
117
126
  * Placeholder text shown in the search field when empty.
118
- * @default "Search movies and videos..."
127
+ * @default "Search..."
119
128
  */
120
129
  placeholder?: string;
121
130
 
131
+ /**
132
+ * Programmatically set the search field text.
133
+ * Works like React Native TextInput's `value` + `onChangeText` pattern.
134
+ * Useful for restoring search state, deep links, or "search for similar" flows.
135
+ *
136
+ * **Warning:** Avoid setting `searchText` inside your `onSearch` handler with
137
+ * transforms (e.g., trimming, lowercasing). The native guard only prevents
138
+ * same-value loops — transformed values will trigger a new `onSearch` event,
139
+ * creating an infinite update cycle.
140
+ */
141
+ searchText?: string;
142
+
122
143
  /**
123
144
  * Whether to show a loading indicator.
124
145
  * @default false
@@ -178,7 +199,7 @@ export interface TvosSearchViewProps {
178
199
 
179
200
  /**
180
201
  * Text displayed when the search field is empty and no results are shown.
181
- * @default "Search for movies and videos"
202
+ * @default "Search your library"
182
203
  */
183
204
  emptyStateText?: string;
184
205
 
@@ -266,6 +287,9 @@ export interface TvosSearchViewProps {
266
287
  /**
267
288
  * Callback fired when the search text changes.
268
289
  * Debounce this handler to avoid excessive API calls.
290
+ *
291
+ * **Note:** If using the `searchText` prop, do not set it to a transformed
292
+ * value inside this handler — see `searchText` docs for loop prevention.
269
293
  */
270
294
  onSearch: (event: SearchEvent) => void;
271
295
 
@@ -301,6 +325,38 @@ export interface TvosSearchViewProps {
301
325
  */
302
326
  onValidationWarning?: (event: ValidationWarningEvent) => void;
303
327
 
328
+ /**
329
+ * Optional callback fired when the native search field gains focus.
330
+ * Use this to disable RN gesture handlers via TVEventControl if the
331
+ * automatic gesture handling doesn't work on your device.
332
+ *
333
+ * @example
334
+ * ```tsx
335
+ * import { TVEventControl } from 'react-native';
336
+ *
337
+ * onSearchFieldFocused={() => {
338
+ * TVEventControl.disableGestureHandlersCancelTouches();
339
+ * }}
340
+ * ```
341
+ */
342
+ onSearchFieldFocused?: (event: SearchFieldFocusEvent) => void;
343
+
344
+ /**
345
+ * Optional callback fired when the native search field loses focus.
346
+ * Use this to re-enable RN gesture handlers via TVEventControl if you
347
+ * disabled them in onSearchFieldFocused.
348
+ *
349
+ * @example
350
+ * ```tsx
351
+ * import { TVEventControl } from 'react-native';
352
+ *
353
+ * onSearchFieldBlurred={() => {
354
+ * TVEventControl.enableGestureHandlersCancelTouches();
355
+ * }}
356
+ * ```
357
+ */
358
+ onSearchFieldBlurred?: (event: SearchFieldFocusEvent) => void;
359
+
304
360
  /**
305
361
  * Optional style for the view container.
306
362
  */