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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
*/
|