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/README.md CHANGED
@@ -3,31 +3,18 @@
3
3
  [![npm version](https://img.shields.io/npm/v/expo-tvos-search.svg)](https://www.npmjs.com/package/expo-tvos-search)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Test Status](https://github.com/keiver/expo-tvos-search/workflows/Test%20PR/badge.svg)](https://github.com/keiver/expo-tvos-search/actions)
6
- [![Bundle Size](https://img.shields.io/bundlephobia/minzip/expo-tvos-search)](https://bundlephobia.com/package/expo-tvos-search)
7
6
 
8
- A native tvOS search component for Expo and React Native using SwiftUI's `.searchable` modifier. Handles focus, keyboard navigation, and accessibility out of the box.
7
+ A native tvOS search component for Expo and React Native using SwiftUI's `.searchable` modifier. Provides the native tvOS search experience with automatic focus handling, remote control support, and flexible customization for media apps.
8
+
9
+ **Platform Support:**
10
+ - tvOS 15.0+
11
+ - Expo SDK 51+
12
+ - React Native tvOS 0.71+
9
13
 
10
14
  <p align="center">
11
- <img src="screenshots/results.png" width="700" alt="TomoTV Search Results"/>
15
+ <img src="screenshots/default.png" alt="expo-tvos-search fullscreen native tvOS search component" style="border-radius: 16px;max-width: 100%;"/>
12
16
  </p>
13
17
 
14
- <table>
15
- <tr>
16
- <td align="center">
17
- <img src="screenshots/default.png" width="280" alt="Search"/><br/>
18
- <sub>Native Search</sub>
19
- </td>
20
- <td align="center">
21
- <img src="screenshots/results.png" width="280" alt="Results"/><br/>
22
- <sub>Results</sub>
23
- </td>
24
- <td align="center">
25
- <img src="screenshots/no-results.png" width="280" alt="No Results"/><br/>
26
- <sub>Empty State</sub>
27
- </td>
28
- </tr>
29
- </table>
30
-
31
18
  ## Installation
32
19
 
33
20
  ```bash
@@ -40,30 +27,18 @@ Or install from GitHub:
40
27
  npx expo install github:keiver/expo-tvos-search
41
28
  ```
42
29
 
43
- Then follow the **tvOS prerequisites** below and rebuild your native project.
44
-
45
- ## Prerequisites for tvOS Builds (Expo)
46
-
47
- Your project must be configured for React Native tvOS to build and run this module.
48
-
49
- **Quick Checklist:**
30
+ ## Prerequisites for tvOS Builds
50
31
 
51
- - `react-native-tvos` in use
52
- - ✅ `@react-native-tvos/config-tv` installed + added to Expo plugins
53
- - ✅ Run prebuild with `EXPO_TV=1`
32
+ Your project must be configured for React Native tvOS to use this module.
54
33
 
55
- ### 1. Swap to react-native-tvos
56
-
57
- Replace `react-native` with the [tvOS fork](https://github.com/react-native-tvos/react-native-tvos):
34
+ ### 1. Install react-native-tvos
58
35
 
59
36
  ```bash
60
- npm remove react-native && npm install react-native-tvos@latest
37
+ npm install react-native-tvos@latest
61
38
  ```
62
39
 
63
40
  ### 2. Install the tvOS config plugin
64
41
 
65
- Install:
66
-
67
42
  ```bash
68
43
  npx expo install @react-native-tvos/config-tv
69
44
  ```
@@ -78,49 +53,35 @@ Then add the plugin in `app.json` / `app.config.js`:
78
53
  }
79
54
  ```
80
55
 
81
- ### 3. Generate native projects with tvOS enabled
82
-
83
- ```bash
84
- EXPO_TV=1 npx expo prebuild --clean
85
- ```
86
-
87
- Then run:
88
-
89
- ```bash
90
- npx expo run:ios
91
- ```
92
-
93
- ### 4. Common gotchas
94
-
95
- - **Prebuild must be re-run** if you add/remove tvOS dependencies or change the tvOS plugin configuration.
96
- - **If you see App Transport Security errors** for images, ensure your `imageUrl` uses `https://` (recommended) or add the appropriate ATS exceptions.
97
- - **If the tvOS keyboard/search UI doesn't appear**, confirm you're actually running a tvOS target/simulator, not an iOS target.
98
-
99
56
  ## Usage
100
57
 
58
+ This example from the demo's [Portrait tab](https://github.com/keiver/expo-tvos-search-demo/blob/main/app/(tabs)/portrait.tsx) shows a complete implementation:
59
+
101
60
  ```tsx
102
- import React, { useState } from "react";
103
- import { Alert } from "react-native";
61
+ import { useState } from 'react';
62
+ import { Alert } from 'react-native';
63
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
64
+ import { LinearGradient } from 'expo-linear-gradient';
104
65
  import {
105
66
  TvosSearchView,
106
67
  isNativeSearchAvailable,
107
68
  type SearchResult,
108
- } from "expo-tvos-search";
69
+ } from 'expo-tvos-search';
109
70
 
110
71
  const PLANETS: SearchResult[] = [
111
- { id: "mercury", title: "Mercury", subtitle: "Smallest planet", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Mercury_in_true_color.jpg/400px-Mercury_in_true_color.jpg" },
112
- { id: "venus", title: "Venus", subtitle: "Hottest planet", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Venus-real_color.jpg/400px-Venus-real_color.jpg" },
113
- { id: "earth", title: "Earth", subtitle: "Our home", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/The_Earth_seen_from_Apollo_17.jpg/400px-The_Earth_seen_from_Apollo_17.jpg" },
114
- { id: "mars", title: "Mars", subtitle: "The red planet", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/OSIRIS_Mars_true_color.jpg/400px-OSIRIS_Mars_true_color.jpg" },
115
- { id: "jupiter", title: "Jupiter", subtitle: "Largest planet", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/Jupiter_New_Horizons.jpg/400px-Jupiter_New_Horizons.jpg" },
116
- { id: "saturn", title: "Saturn", subtitle: "Ringed giant", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/Saturn_during_Equinox.jpg/400px-Saturn_during_Equinox.jpg" },
117
- { id: "uranus", title: "Uranus", subtitle: "Ice giant", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Uranus2.jpg/400px-Uranus2.jpg" },
118
- { id: "neptune", title: "Neptune", subtitle: "Windiest planet", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Neptune_-_Voyager_2_%2829347980845%29_flatten_crop.jpg/400px-Neptune_-_Voyager_2_%2829347980845%29_flatten_crop.jpg" },
72
+ {
73
+ id: 'earth',
74
+ title: 'Earth - The Blue Marble of Life',
75
+ subtitle: 'Our home planet, the only known world to harbor life',
76
+ imageUrl: require('./assets/planets/earth.webp'),
77
+ },
78
+ // ... all planets
119
79
  ];
120
80
 
121
- export function SearchScreen() {
81
+ export default function SearchScreen() {
122
82
  const [results, setResults] = useState<SearchResult[]>([]);
123
83
  const [isLoading, setIsLoading] = useState(false);
84
+ const insets = useSafeAreaInsets();
124
85
 
125
86
  const handleSearch = (event: { nativeEvent: { query: string } }) => {
126
87
  const { query } = event.nativeEvent;
@@ -132,11 +93,12 @@ export function SearchScreen() {
132
93
 
133
94
  setIsLoading(true);
134
95
 
135
- // Simulate async search
96
+ // Debounce dummy search (300ms)
136
97
  setTimeout(() => {
137
- const filtered = PLANETS.filter((planet) =>
138
- planet.title.toLowerCase().includes(query.toLowerCase()) ||
139
- planet.subtitle?.toLowerCase().includes(query.toLowerCase())
98
+ const filtered = PLANETS.filter(
99
+ planet =>
100
+ planet.title.toLowerCase().includes(query.toLowerCase()) ||
101
+ planet.subtitle?.toLowerCase().includes(query.toLowerCase())
140
102
  );
141
103
  setResults(filtered);
142
104
  setIsLoading(false);
@@ -144,207 +106,251 @@ export function SearchScreen() {
144
106
  };
145
107
 
146
108
  const handleSelect = (event: { nativeEvent: { id: string } }) => {
147
- const planet = PLANETS.find((p) => p.id === event.nativeEvent.id);
109
+ const planet = PLANETS.find(p => p.id === event.nativeEvent.id);
148
110
  if (planet) {
149
111
  Alert.alert(planet.title, planet.subtitle);
150
112
  }
151
113
  };
152
114
 
153
115
  if (!isNativeSearchAvailable()) {
154
- return <YourFallbackSearch />;
116
+ return null; // Or show web fallback
155
117
  }
156
118
 
157
119
  return (
158
- <TvosSearchView
159
- results={results}
160
- columns={5}
161
- placeholder="Search planets..."
162
- isLoading={isLoading}
163
- topInset={140}
164
- onSearch={handleSearch}
165
- onSelectItem={handleSelect}
120
+ <LinearGradient
121
+ colors={['#0f172a', '#1e293b', '#0f172a']}
166
122
  style={{ flex: 1 }}
167
- />
123
+ >
124
+ <TvosSearchView
125
+ results={results}
126
+ columns={4}
127
+ placeholder="Search planets..."
128
+ isLoading={isLoading}
129
+ topInset={insets.top + 80}
130
+ onSearch={handleSearch}
131
+ onSelectItem={handleSelect}
132
+ textColor="#E5E5E5"
133
+ accentColor="#E50914"
134
+ cardWidth={280}
135
+ cardHeight={420}
136
+ overlayTitleSize={18}
137
+ style={{ flex: 1 }}
138
+ />
139
+ </LinearGradient>
168
140
  );
169
141
  }
170
142
  ```
171
143
 
172
- > **⚠️ IMPORTANT**: Always debounce the `onSearch` callback to prevent excessive API calls or expensive operations on every keystroke. In production apps, use a debounce library like lodash or implement your own debounce function:
173
- >
174
- > ```tsx
175
- > import { useMemo } from 'react';
176
- > import { debounce } from 'lodash'; // or your preferred debounce implementation
177
- >
178
- > function SearchScreen() {
179
- > const [results, setResults] = useState([]);
180
- >
181
- > const debouncedSearch = useMemo(
182
- > () => debounce((query: string) => {
183
- > // Your actual search logic (API call, etc.)
184
- > fetchSearchResults(query).then(setResults);
185
- > }, 300), // 300ms delay
186
- > []
187
- > );
188
- >
189
- > return (
190
- > <TvosSearchView
191
- > results={results}
192
- > onSearch={(e) => debouncedSearch(e.nativeEvent.query)}
193
- > onSelectItem={handleSelect}
194
- > />
195
- > );
196
- > }
197
- > ```
198
-
199
- ### Error Handling and Monitoring
200
-
201
- The library provides error and validation warning callbacks for production monitoring:
144
+ <p align="center">
145
+ <img src="screenshots/no-results.png" alt="No results for search screen using expo-tvos-search" style="border-radius: 16px;max-width: 100%;"/><br/>
146
+ </p>
147
+
148
+ ## Demo App and Common Configurations
149
+
150
+ Explore all configurations in the [demo app](https://github.com/keiver/expo-tvos-search-demo).
151
+
152
+ ### Portrait Cards
153
+
154
+ ```tsx
155
+ <TvosSearchView
156
+ columns={4}
157
+ cardWidth={280}
158
+ cardHeight={420}
159
+ overlayTitleSize={18}
160
+ // ... other props
161
+ />
162
+ ```
163
+
164
+ ### Landscape Cards
165
+
166
+ ```tsx
167
+ <TvosSearchView
168
+ columns={3}
169
+ cardWidth={500}
170
+ cardHeight={280}
171
+ // ... other props
172
+ />
173
+ ```
174
+
175
+ ### Mini Grid
176
+
177
+ ```tsx
178
+ <TvosSearchView
179
+ columns={5}
180
+ cardWidth={240}
181
+ cardHeight={360}
182
+ cardMargin={60} // v1.3.0 - extra spacing
183
+ // ... other props
184
+ />
185
+ ```
186
+
187
+ ### External Titles
188
+
189
+ ```tsx
190
+ <TvosSearchView
191
+ showTitle={true}
192
+ showSubtitle={true}
193
+ showTitleOverlay={false}
194
+ // ... other props
195
+ />
196
+ ```
197
+
198
+ ### Error Handling
202
199
 
203
200
  ```tsx
204
201
  <TvosSearchView
205
- results={results}
206
- onSearch={handleSearch}
207
- onSelectItem={handleSelect}
208
202
  onError={(e) => {
209
203
  const { category, message, context } = e.nativeEvent;
210
- // Log to your error monitoring service
211
204
  console.error(`[Search Error] ${category}: ${message}`, context);
212
- // Examples: 'module_unavailable', 'validation_failed', 'image_load_failed', 'unknown'
213
205
  }}
214
206
  onValidationWarning={(e) => {
215
207
  const { type, message, context } = e.nativeEvent;
216
- // Log non-fatal issues for monitoring
217
208
  console.warn(`[Validation] ${type}: ${message}`, context);
218
- // Examples: 'field_truncated', 'value_clamped', 'url_invalid', 'validation_failed'
219
209
  }}
210
+ // ... other props
220
211
  />
221
212
  ```
222
213
 
223
- These callbacks help you:
224
- - Monitor data quality issues (truncated fields, invalid URLs)
225
- - Track when props are clamped to safe ranges
226
- - Detect when results exceed the 500-item limit
227
- - Log errors for debugging in production
214
+ ### Apple TV Hardware Keyboard Support (v1.3.2+)
228
215
 
229
- ### Customizing Colors and Card Dimensions
216
+ On real Apple TV hardware, React Native's `RCTTVRemoteHandler` installs gesture recognizers that consume Siri Remote presses before they reach SwiftUI's `.searchable` text field, which prevents keyboard input. When the search field gains focus, this component temporarily disables touch cancellation using the official `react-native-tvos` notification API, and also disables tap/long-press recognizers from parent views (to cover cases like `react-native-gesture-handler`). Swipe and pan recognizers stay active for keyboard navigation. Everything is restored when focus leaves the field. This only applies to physical devices -- the Simulator doesn't need it.
217
+
218
+ If this interferes with gesture handling in your app, please [open an issue](https://github.com/keiver/expo-tvos-search/issues) so we can sort it out.
230
219
 
231
- You can customize the appearance of the search interface with color and dimension props:
220
+ For additional control, you can use the focus callbacks with `TVEventControl`:
232
221
 
233
222
  ```tsx
223
+ import { TVEventControl } from 'react-native';
224
+
234
225
  <TvosSearchView
235
- results={results}
236
- onSearch={handleSearch}
237
- onSelectItem={handleSelect}
238
- // Custom colors
239
- textColor="#E5E5E5" // Light gray text on dark background
240
- accentColor="#E50914" // Red focused borders (House Flix style 😂)
241
- // Custom card dimensions
242
- cardWidth={420} // Landscape cards
243
- cardHeight={240} // 16:9 aspect ratio
244
- style={{ flex: 1 }}
226
+ onSearchFieldFocused={() => {
227
+ TVEventControl.disableGestureHandlersCancelTouches();
228
+ }}
229
+ onSearchFieldBlurred={() => {
230
+ TVEventControl.enableGestureHandlersCancelTouches();
231
+ }}
232
+ // ... other props
245
233
  />
246
234
  ```
247
235
 
248
- **Color props:**
249
- - `textColor` - Affects subtitle text, empty state text, and placeholder icons
250
- - `accentColor` - Affects focused card borders and highlights
251
-
252
- **Dimension props:**
253
- - Default: 280x420 (portrait, 2:3 aspect ratio)
254
- - Landscape example: 420x240 (16:9)
255
- - Square example: 300x300 (1:1)
256
-
257
- ### Layout and Spacing Customization
258
-
259
- Control image display, card spacing, and overlay padding for different layouts:
236
+ ### Customizing Colors and Card Dimensions
260
237
 
261
238
  ```tsx
262
239
  <TvosSearchView
263
- results={results}
264
- onSearch={handleSearch}
265
- onSelectItem={handleSelect}
266
- // Image display mode
267
- imageContentMode="fit" // 'fill' (default, crop), 'fit'/'contain' (letterbox)
268
- // Card spacing
269
- cardMargin={60} // Space between cards (default: 40)
270
- cardPadding={20} // Padding inside overlay (default: 16)
271
- style={{ flex: 1 }}
240
+ textColor="#E5E5E5"
241
+ accentColor="#E50914"
242
+ cardWidth={420}
243
+ cardHeight={240}
244
+ // ... other props
272
245
  />
273
246
  ```
274
247
 
275
- **Layout props:**
276
- - `imageContentMode` - How images fill cards:
277
- - `'fill'` (default) - Crops image to fill entire card
278
- - `'fit'` or `'contain'` - Shows entire image, may add letterboxing
279
- - `cardMargin` - Controls spacing between cards in the grid (both horizontal and vertical)
280
- - `cardPadding` - Controls padding inside the card's title overlay for better text spacing
281
-
282
- **Common use cases:**
283
- - **Portrait posters** (movie/TV): `imageContentMode="fill"` + `cardMargin={40}`
284
- - **Landscape thumbnails** (episodes): `imageContentMode="fit"` + `cardMargin={60}`
285
- - **Compact grid** (music): `cardMargin={20}` + `cardPadding={12}`
286
- - **Spacious layout** (photos): `cardMargin={60}` + `cardPadding={20}`
248
+ ### Title Overlay Customization (v1.3.0+)
287
249
 
288
- ## Example App
250
+ ```tsx
251
+ <TvosSearchView
252
+ overlayTitleSize={22}
253
+ enableMarquee={true}
254
+ marqueeDelay={1.5}
255
+ // ... other props
256
+ />
257
+ ```
289
258
 
290
- **[Tomo TV](https://github.com/keiver/tomotv)** is a tvOS application that uses `expo-tvos-search` in a real-world Jellyfin client. It demonstrates the search component integrated with a complete media browsing experience, including:
259
+ ### Layout Spacing (v1.3.0+)
291
260
 
292
- - Search interaction with tvOS remote
293
- - Focus navigation through results
294
- - Integration with a live media library
295
- - Complete setup instructions and screenshots
261
+ ```tsx
262
+ <TvosSearchView
263
+ cardMargin={60}
264
+ cardPadding={25}
265
+ // ... other props
266
+ />
267
+ ```
296
268
 
297
- Check out Tomo TV to see `expo-tvos-search` in action and reference its implementation for your own projects.
269
+ ### Image Display Mode
298
270
 
299
- ## See it in action:
271
+ ```tsx
272
+ <TvosSearchView
273
+ imageContentMode="fit" // 'fill' (crop), 'fit'/'contain' (letterbox)
274
+ // ... other props
275
+ />
276
+ ```
300
277
 
301
278
  <p align="center">
302
- <img src="screenshots/expo-tvos-search.gif" width="700" alt="expo-tvos-search screen in action" loading="lazy" />
279
+ <img src="screenshots/results.png" alt="Results for search screen using expo-tvos-search" style="border-radius: 16px;max-width: 100%;"/><br/>
303
280
  </p>
304
281
 
305
282
  ## Props
306
283
 
284
+ ### Core Props
285
+
307
286
  | Prop | Type | Default | Description |
308
287
  |------|------|---------|-------------|
309
288
  | `results` | `SearchResult[]` | `[]` | Array of search results |
310
289
  | `columns` | `number` | `5` | Number of columns in the grid |
311
- | `placeholder` | `string` | `"Search movies and videos..."` | Search field placeholder |
290
+ | `placeholder` | `string` | `"Search..."` | Search field placeholder |
291
+ | `searchText` | `string` | — | Programmatically set search field text (restore state, deep links) |
312
292
  | `isLoading` | `boolean` | `false` | Shows loading indicator |
293
+
294
+ ### Card Dimensions & Spacing
295
+
296
+ | Prop | Type | Default | Description |
297
+ |------|------|---------|-------------|
298
+ | `cardWidth` | `number` | `280` | Width of each result card in points |
299
+ | `cardHeight` | `number` | `420` | Height of each result card in points |
300
+ | `cardMargin` | `number` | `40` | **(v1.3.0+)** Spacing between cards in the grid (horizontal and vertical) |
301
+ | `cardPadding` | `number` | `16` | **(v1.3.0+)** Padding inside the card for overlay content (title/subtitle) |
302
+ | `topInset` | `number` | `0` | Top padding (for tab bar clearance) |
303
+
304
+ ### Display Options
305
+
306
+ | Prop | Type | Default | Description |
307
+ |------|------|---------|-------------|
313
308
  | `showTitle` | `boolean` | `false` | Show title below each result |
314
309
  | `showSubtitle` | `boolean` | `false` | Show subtitle below title |
315
- | `showFocusBorder` | `boolean` | `false` | Show border on focused item |
316
- | `topInset` | `number` | `0` | Top padding (for tab bar clearance) |
317
310
  | `showTitleOverlay` | `boolean` | `true` | Show title overlay with gradient at bottom of card |
311
+ | `showFocusBorder` | `boolean` | `false` | Show border on focused item |
312
+ | `imageContentMode` | `'fill' \| 'fit' \| 'contain'` | `'fill'` | How images fill the card: `fill` (crop to fill), `fit`/`contain` (letterbox) |
313
+
314
+ ### Styling & Colors
315
+
316
+ | Prop | Type | Default | Description |
317
+ |------|------|---------|-------------|
318
+ | `textColor` | `string` | system default | Color for text and UI elements (hex format, e.g., "#FFFFFF") |
319
+ | `accentColor` | `string` | `"#FFC312"` | Accent color for focused elements (hex format, e.g., "#FFC312") |
320
+ | `overlayTitleSize` | `number` | `20` | **(v1.3.0+)** Font size for title text in the blur overlay (when showTitleOverlay is true) |
321
+
322
+ ### Animation
323
+
324
+ | Prop | Type | Default | Description |
325
+ |------|------|---------|-------------|
318
326
  | `enableMarquee` | `boolean` | `true` | Enable marquee scrolling for long titles |
319
327
  | `marqueeDelay` | `number` | `1.5` | Delay in seconds before marquee starts |
320
- | `emptyStateText` | `string` | `"Search for movies and videos"` | Text shown when search field is empty |
328
+
329
+ ### Text Customization
330
+
331
+ | Prop | Type | Default | Description |
332
+ |------|------|---------|-------------|
333
+ | `emptyStateText` | `string` | `"Search your library"` | Text shown when search field is empty |
321
334
  | `searchingText` | `string` | `"Searching..."` | Text shown during search |
322
335
  | `noResultsText` | `string` | `"No results found"` | Text shown when no results found |
323
336
  | `noResultsHintText` | `string` | `"Try a different search term"` | Hint text below no results message |
324
- | `textColor` | `string` | system default | Color for text and UI elements (hex format, e.g., "#FFFFFF") |
325
- | `accentColor` | `string` | `"#FFC312"` | Accent color for focused elements (hex format, e.g., "#FFC312") |
326
- | `cardWidth` | `number` | `280` | Width of each result card in points |
327
- | `cardHeight` | `number` | `420` | Height of each result card in points |
328
- | `imageContentMode` | `'fill' \| 'fit' \| 'contain'` | `'fill'` | How images fill the card: `fill` (crop to fill), `fit`/`contain` (letterbox) |
329
- | `cardMargin` | `number` | `40` | Spacing between cards in the grid (horizontal and vertical) |
330
- | `cardPadding` | `number` | `16` | Padding inside the card for overlay content (title/subtitle) |
331
- | `overlayTitleSize` | `number` | `20` | Font size for title text in the blur overlay (when showTitleOverlay is true) |
337
+
338
+ ### Event Handlers
339
+
340
+ | Prop | Type | Default | Description |
341
+ |------|------|---------|-------------|
332
342
  | `onSearch` | `function` | required | Called when search text changes |
333
343
  | `onSelectItem` | `function` | required | Called when result is selected |
334
- | `onError` | `function` | optional | Called when errors occur (image loading failures, validation errors) |
335
- | `onValidationWarning` | `function` | optional | Called for non-fatal warnings (truncated fields, clamped values, invalid URLs) |
336
- | `style` | `ViewStyle` | optional | Style object for the view container |
344
+ | `onError` | `function` | optional | **(v1.2.0+)** Called when errors occur (image loading failures, validation errors) |
345
+ | `onValidationWarning` | `function` | optional | **(v1.2.0+)** Called for non-fatal warnings (truncated fields, clamped values, invalid URLs) |
346
+ | `onSearchFieldFocused` | `function` | optional | **(v1.3.2+)** Called when native search field gains focus. Use with `TVEventControl` for Apple TV hardware keyboard support. |
347
+ | `onSearchFieldBlurred` | `function` | optional | **(v1.3.2+)** Called when native search field loses focus. Use to re-enable gesture handlers. |
337
348
 
338
- ## SearchResult Type
349
+ ### Other
339
350
 
340
- ```typescript
341
- interface SearchResult {
342
- id: string;
343
- title: string;
344
- subtitle?: string;
345
- imageUrl?: string;
346
- }
347
- ```
351
+ | Prop | Type | Default | Description |
352
+ |------|------|---------|-------------|
353
+ | `style` | `ViewStyle` | optional | Style object for the view container |
348
354
 
349
355
  ## Result Handling
350
356
 
@@ -352,117 +358,9 @@ The native implementation applies the following validation and constraints:
352
358
 
353
359
  - **Maximum results**: The results array is capped at 500 items. Any results beyond this limit are silently ignored.
354
360
  - **Required fields**: Results with empty `id` or `title` are automatically filtered out and not displayed.
355
- - **Image URL schemes**: Only HTTP and HTTPS URLs are accepted for `imageUrl`. Other URL schemes (e.g., `file://`, `data:`) are rejected.
361
+ - **Image URL schemes**: HTTP, HTTPS, and `data:` URIs are accepted for `imageUrl`. Other URL schemes (e.g., `file://`) are rejected.
356
362
  - **HTTPS recommended**: HTTP URLs may be blocked by App Transport Security on tvOS unless explicitly allowed in Info.plist.
357
363
 
358
- ## Focus Handling - Do's and Don'ts
359
-
360
- The native `.searchable` modifier manages focus automatically. Here's what to do and what to avoid:
361
-
362
- ### ✅ Do: Render directly in your screen
363
-
364
- ```tsx
365
- function SearchScreen() {
366
- return (
367
- <TvosSearchView
368
- results={results}
369
- onSearch={handleSearch}
370
- onSelectItem={handleSelect}
371
- style={{ flex: 1 }}
372
- />
373
- );
374
- }
375
- ```
376
-
377
- ### ❌ Don't: Wrap in focusable containers
378
-
379
- ```tsx
380
- // ❌ WRONG - breaks focus navigation
381
- function SearchScreen() {
382
- return (
383
- <Pressable> {/* Don't wrap in Pressable */}
384
- <TvosSearchView ... />
385
- </Pressable>
386
- );
387
- }
388
-
389
- // ❌ WRONG - interferes with native focus
390
- function SearchScreen() {
391
- return (
392
- <TouchableOpacity> {/* Don't wrap in TouchableOpacity */}
393
- <TvosSearchView ... />
394
- </TouchableOpacity>
395
- );
396
- }
397
- ```
398
-
399
- **Why this breaks**: Focusable wrappers steal focus from the native SwiftUI search container, which breaks directional navigation.
400
-
401
- ### ✅ Do: Use non-interactive containers
402
-
403
- ```tsx
404
- // ✅ CORRECT - View is not focusable
405
- function SearchScreen() {
406
- return (
407
- <View style={{ flex: 1, backgroundColor: '#000' }}>
408
- <TvosSearchView ... />
409
- </View>
410
- );
411
- }
412
-
413
- // ✅ CORRECT - SafeAreaView is not focusable
414
- function SearchScreen() {
415
- return (
416
- <SafeAreaView style={{ flex: 1 }}>
417
- <TvosSearchView ... />
418
- </SafeAreaView>
419
- );
420
- }
421
- ```
422
-
423
- ## Requirements
424
-
425
- - Node.js 18+
426
- - Expo SDK 51+
427
- - tvOS 15+
428
- - Project configured for tvOS (`react-native-tvos` + `@react-native-tvos/config-tv`)
429
-
430
- ## Troubleshooting
431
-
432
- ### Native module not found
433
-
434
- If you see `requireNativeViewManager("ExpoTvosSearch") returned null`, the native module hasn't been built:
435
-
436
- ```bash
437
- # Clean and rebuild with tvOS support
438
- EXPO_TV=1 npx expo prebuild --clean
439
- npx expo run:ios
440
- ```
441
-
442
- **Note:** Expo Go doesn't support this. Build a dev client or native build instead.
443
-
444
- ### Images not loading
445
-
446
- 1. Verify your image URLs are HTTPS (HTTP may be blocked by App Transport Security)
447
- 2. Ensure required authentication parameters are included in image URLs
448
- 3. For local development, ensure your server is accessible from the Apple TV
449
-
450
- ### Focus issues
451
-
452
- If focus doesn't move correctly:
453
-
454
- 1. Ensure `columns` prop matches your layout (default: 5)
455
- 2. Check `topInset` if the first row is hidden under the tab bar
456
- 3. The native `.searchable` modifier handles focus automatically - avoid wrapping in focusable containers
457
-
458
- ### Marquee not scrolling
459
-
460
- If long titles don't scroll when focused:
461
-
462
- 1. Verify `enableMarquee={true}` (default)
463
- 2. Check `marqueeDelay` - scrolling starts after this delay (default: 1.5s)
464
- 3. Text only scrolls if it overflows the card width
465
-
466
364
  ## Testing
467
365
 
468
366
  Run TypeScript tests:
@@ -474,6 +372,7 @@ npm run test:coverage # Generate coverage report
474
372
  ```
475
373
 
476
374
  Tests cover:
375
+
477
376
  - `isNativeSearchAvailable()` behavior on different platforms
478
377
  - Component rendering when native module is unavailable
479
378
  - Event structure validation
@@ -481,6 +380,7 @@ Tests cover:
481
380
  ## Contributing
482
381
 
483
382
  We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
383
+
484
384
  - Code of conduct
485
385
  - Development setup
486
386
  - Testing requirements
@@ -489,8 +389,8 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guid
489
389
 
490
390
  ### Adding New Props
491
391
 
492
- If you're adding new props to the library, follow the comprehensive checklist in [.claude/CLAUDE-adding-new-props.md](.claude/CLAUDE-adding-new-props.md). This memory bank provides a 9-step guide ensuring props are properly wired from TypeScript through to Swift rendering.
392
+ If you're adding new props to the library, follow the comprehensive checklist in [CLAUDE-adding-new-props.md](./CLAUDE-adding-new-props.md). This memory bank provides a 9-step guide ensuring props are properly wired from TypeScript through to Swift rendering.
493
393
 
494
394
  ## License
495
395
 
496
- MIT
396
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.