expo-tvos-search 1.2.3 → 1.3.1

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,19 @@
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)
6
7
 
7
- 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.
8
+ 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.
9
+
10
+ **Platform Support:**
11
+ - tvOS 15.0+
12
+ - Expo SDK 51+
13
+ - React Native tvOS 0.71+
8
14
 
9
15
  <p align="center">
10
- <img src="screenshots/results.png" width="700" alt="TomoTV Search Results"/>
16
+ <img src="screenshots/demo-mini.png" width="80%" alt="Demo Mini screen for expo-tvos-search" style="border-radius: 16px;max-width: 100%;"/>
11
17
  </p>
12
18
 
13
- <table>
14
- <tr>
15
- <td align="center">
16
- <img src="screenshots/default.png" width="280" alt="Search"/><br/>
17
- <sub>Native Search</sub>
18
- </td>
19
- <td align="center">
20
- <img src="screenshots/results.png" width="280" alt="Results"/><br/>
21
- <sub>Results</sub>
22
- </td>
23
- <td align="center">
24
- <img src="screenshots/no-results.png" width="280" alt="No Results"/><br/>
25
- <sub>Empty State</sub>
26
- </td>
27
- </tr>
28
- </table>
29
-
30
-
31
19
  ## Installation
32
20
 
33
21
  ```bash
@@ -40,72 +28,454 @@ Or install from GitHub:
40
28
  npx expo install github:keiver/expo-tvos-search
41
29
  ```
42
30
 
43
- Then rebuild your native project:
31
+ Then follow the **tvOS prerequisites** below and rebuild your native project.
32
+
33
+ ## Quick Start
34
+
35
+ ### Try the Demo App
36
+
37
+ **[expo-tvos-search-demo](https://github.com/keiver/expo-tvos-search-demo)** - Comprehensive showcase with 7 tabs demonstrating all library features:
38
+
39
+ - **Default** - 4-column grid with custom colors
40
+ - **Portrait** - Netflix-style tall cards (280×420)
41
+ - **Landscape** - Wide 16:9 cards (500×280)
42
+ - **Mini** - Compact 5-column layout (240×360)
43
+ - **External Title** - Titles displayed below cards
44
+ - **Minimal** - Bare minimum setup (5 props)
45
+ - **Help** - Feature overview and usage guide
46
+
47
+ Clone and run:
48
+ ```bash
49
+ git clone https://github.com/keiver/expo-tvos-search-demo.git
50
+ cd expo-tvos-search-demo
51
+ npm install
52
+ npm run prebuild
53
+ npm run tvos
54
+ ```
55
+
56
+ The demo uses a planet search theme with 8 planets (Mercury to Neptune) and demonstrates all library features with real working code.
57
+
58
+ ## Prerequisites for tvOS Builds (Expo)
59
+
60
+ Your project must be configured for React Native tvOS to build and run this module.
61
+
62
+ **Quick Checklist:**
63
+
64
+ - ✅ `react-native-tvos` in use
65
+ - ✅ `@react-native-tvos/config-tv` installed + added to Expo plugins
66
+ - ✅ Run prebuild with `EXPO_TV=1`
67
+
68
+ ### 1. Swap to react-native-tvos
69
+
70
+ Replace `react-native` with the [tvOS fork](https://github.com/react-native-tvos/react-native-tvos):
71
+
72
+ ```bash
73
+ npm remove react-native && npm install react-native-tvos@latest
74
+ ```
75
+
76
+ ### 2. Install the tvOS config plugin
77
+
78
+ Install:
79
+
80
+ ```bash
81
+ npx expo install @react-native-tvos/config-tv
82
+ ```
83
+
84
+ Then add the plugin in `app.json` / `app.config.js`:
85
+
86
+ ```json
87
+ {
88
+ "expo": {
89
+ "plugins": ["@react-native-tvos/config-tv"]
90
+ }
91
+ }
92
+ ```
93
+
94
+ ### 3. Generate native projects with tvOS enabled
44
95
 
45
96
  ```bash
46
97
  EXPO_TV=1 npx expo prebuild --clean
98
+ ```
99
+
100
+ Then run:
101
+
102
+ ```bash
47
103
  npx expo run:ios
48
104
  ```
49
105
 
106
+
50
107
  ## Usage
51
108
 
52
- ```tsx
53
- import { TvosSearchView, isNativeSearchAvailable } from 'expo-tvos-search';
109
+ ### Minimal Example
54
110
 
55
- function SearchScreen() {
56
- const [results, setResults] = useState([]);
57
- const [isLoading, setIsLoading] = useState(false);
111
+ For the absolute minimum setup, see the [Minimal tab](https://github.com/keiver/expo-tvos-search-demo/blob/main/app/(tabs)/minimal.tsx) in the demo app.
112
+
113
+ <p align="center">
114
+ <img src="screenshots/demo-default.png" width="80%" alt="Minimal demo screen for expo-tvos-search" style="border-radius: 16px;max-width: 100%;"/><br/>
115
+ </p>
116
+
117
+ ### Complete Example
118
+
119
+ 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 with best practices:
58
120
 
59
- const handleSearch = (event) => {
60
- const query = event.nativeEvent.query;
61
- // Fetch your results...
121
+ ```tsx
122
+ import { useState } from 'react';
123
+ import { Alert } from 'react-native';
124
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
125
+ import { LinearGradient } from 'expo-linear-gradient';
126
+ import {
127
+ TvosSearchView,
128
+ isNativeSearchAvailable,
129
+ type SearchResult,
130
+ } from 'expo-tvos-search';
131
+
132
+ const PLANETS: SearchResult[] = [
133
+ {
134
+ id: 'earth',
135
+ title: 'Earth - The Blue Marble of Life',
136
+ subtitle: 'Our home planet, the only known world to harbor life',
137
+ imageUrl: require('./assets/planets/earth.webp'),
138
+ },
139
+ // ... more planets
140
+ ];
141
+
142
+ export default function SearchScreen() {
143
+ const [results, setResults] = useState<SearchResult[]>([]);
144
+ const [isLoading, setIsLoading] = useState(false);
145
+ const insets = useSafeAreaInsets();
146
+
147
+ const handleSearch = (event: { nativeEvent: { query: string } }) => {
148
+ const { query } = event.nativeEvent;
149
+
150
+ if (!query.trim()) {
151
+ setResults([]);
152
+ return;
153
+ }
154
+
155
+ setIsLoading(true);
156
+
157
+ // Debounce search (300ms)
158
+ setTimeout(() => {
159
+ const filtered = PLANETS.filter(
160
+ planet =>
161
+ planet.title.toLowerCase().includes(query.toLowerCase()) ||
162
+ planet.subtitle?.toLowerCase().includes(query.toLowerCase())
163
+ );
164
+ setResults(filtered);
165
+ setIsLoading(false);
166
+ }, 300);
62
167
  };
63
168
 
64
- const handleSelect = (event) => {
65
- const id = event.nativeEvent.id;
66
- // Navigate to detail...
169
+ const handleSelect = (event: { nativeEvent: { id: string } }) => {
170
+ const planet = PLANETS.find(p => p.id === event.nativeEvent.id);
171
+ if (planet) {
172
+ Alert.alert(planet.title, planet.subtitle);
173
+ }
67
174
  };
68
175
 
69
176
  if (!isNativeSearchAvailable()) {
70
- return <YourFallbackSearch />;
177
+ return null; // Or show web fallback
71
178
  }
72
179
 
73
180
  return (
74
- <TvosSearchView
75
- results={results}
76
- columns={5}
77
- placeholder="Search..."
78
- isLoading={isLoading}
79
- topInset={140}
80
- onSearch={handleSearch}
81
- onSelectItem={handleSelect}
181
+ <LinearGradient
182
+ colors={['#0f172a', '#1e293b', '#0f172a']}
82
183
  style={{ flex: 1 }}
83
- />
184
+ >
185
+ <TvosSearchView
186
+ results={results}
187
+ columns={4}
188
+ placeholder="Search planets..."
189
+ isLoading={isLoading}
190
+ topInset={insets.top + 80}
191
+ onSearch={handleSearch}
192
+ onSelectItem={handleSelect}
193
+ textColor="#E5E5E5"
194
+ accentColor="#E50914"
195
+ cardWidth={280}
196
+ cardHeight={420}
197
+ overlayTitleSize={18} // v1.3.0 - control title font size
198
+ style={{ flex: 1 }}
199
+ />
200
+ </LinearGradient>
84
201
  );
85
202
  }
86
203
  ```
87
204
 
205
+ ## Layout Styles
206
+
207
+ Explore all 7 configurations in the [demo app](https://github.com/keiver/expo-tvos-search-demo).
208
+
209
+ ### Portrait Cards
210
+
211
+ ```tsx
212
+ <TvosSearchView
213
+ columns={4}
214
+ cardWidth={280}
215
+ cardHeight={420}
216
+ overlayTitleSize={18}
217
+ // ... other props
218
+ />
219
+ ```
220
+
221
+ ### Landscape Cards
222
+
223
+ ```tsx
224
+ <TvosSearchView
225
+ columns={3}
226
+ cardWidth={500}
227
+ cardHeight={280}
228
+ // ... other props
229
+ />
230
+ ```
231
+
232
+ ### Mini Grid
233
+
234
+ ```tsx
235
+ <TvosSearchView
236
+ columns={5}
237
+ cardWidth={240}
238
+ cardHeight={360}
239
+ cardMargin={60} // v1.3.0 - extra spacing
240
+ // ... other props
241
+ />
242
+ ```
243
+
244
+ ### External Titles
245
+
246
+ ```tsx
247
+ <TvosSearchView
248
+ showTitle={true}
249
+ showSubtitle={true}
250
+ showTitleOverlay={false}
251
+ // ... other props
252
+ />
253
+ ```
254
+
255
+ ### Error Handling
256
+
257
+ ```tsx
258
+ <TvosSearchView
259
+ onError={(e) => {
260
+ const { category, message, context } = e.nativeEvent;
261
+ console.error(`[Search Error] ${category}: ${message}`, context);
262
+ }}
263
+ onValidationWarning={(e) => {
264
+ const { type, message, context } = e.nativeEvent;
265
+ console.warn(`[Validation] ${type}: ${message}`, context);
266
+ }}
267
+ // ... other props
268
+ />
269
+ ```
270
+
271
+ ### Customizing Colors and Card Dimensions
272
+
273
+ ```tsx
274
+ <TvosSearchView
275
+ textColor="#E5E5E5"
276
+ accentColor="#E50914"
277
+ cardWidth={420}
278
+ cardHeight={240}
279
+ // ... other props
280
+ />
281
+ ```
282
+
283
+ ### Title Overlay Customization (v1.3.0+)
284
+
285
+ ```tsx
286
+ <TvosSearchView
287
+ overlayTitleSize={22}
288
+ enableMarquee={true}
289
+ marqueeDelay={1.5}
290
+ // ... other props
291
+ />
292
+ ```
293
+
294
+ ### Layout Spacing (v1.3.0+)
295
+
296
+ ```tsx
297
+ <TvosSearchView
298
+ cardMargin={60}
299
+ cardPadding={25}
300
+ // ... other props
301
+ />
302
+ ```
303
+
304
+ ### Image Display Mode
305
+
306
+ ```tsx
307
+ <TvosSearchView
308
+ imageContentMode="fit" // 'fill' (crop), 'fit'/'contain' (letterbox)
309
+ // ... other props
310
+ />
311
+ ```
312
+
313
+ ## TypeScript Support
314
+
315
+ The library provides comprehensive type definitions for all events and props.
316
+
317
+ ### Event Types
318
+
319
+ ```typescript
320
+ import type {
321
+ SearchEvent,
322
+ SelectItemEvent,
323
+ SearchViewErrorEvent,
324
+ ValidationWarningEvent,
325
+ SearchResult,
326
+ } from 'expo-tvos-search';
327
+
328
+ // Search event - fired on text change
329
+ interface SearchEvent {
330
+ nativeEvent: {
331
+ query: string;
332
+ };
333
+ }
334
+
335
+ // Selection event - fired when result is selected
336
+ interface SelectItemEvent {
337
+ nativeEvent: {
338
+ id: string;
339
+ };
340
+ }
341
+
342
+ // Error event - fatal errors (v1.2.0+)
343
+ interface SearchViewErrorEvent {
344
+ nativeEvent: {
345
+ category: 'module_unavailable' | 'validation_failed' | 'image_load_failed' | 'unknown';
346
+ message: string;
347
+ context?: string;
348
+ };
349
+ }
350
+
351
+ // Validation warning - non-fatal issues (v1.2.0+)
352
+ interface ValidationWarningEvent {
353
+ nativeEvent: {
354
+ type: 'field_truncated' | 'value_clamped' | 'url_invalid' | 'validation_failed';
355
+ message: string;
356
+ context?: string;
357
+ };
358
+ }
359
+
360
+ // Search result shape
361
+ interface SearchResult {
362
+ id: string; // Required, max 500 chars
363
+ title: string; // Required, max 500 chars
364
+ subtitle?: string; // Optional, max 500 chars
365
+ imageUrl?: string; // Optional, HTTPS recommended
366
+ }
367
+ ```
368
+
369
+ ### Typed Usage
370
+
371
+ ```typescript
372
+ const handleSearch = (event: SearchEvent) => {
373
+ const query = event.nativeEvent.query;
374
+ // TypeScript knows query is a string
375
+ };
376
+
377
+ const handleSelect = (event: SelectItemEvent) => {
378
+ const id = event.nativeEvent.id;
379
+ // TypeScript knows id is a string
380
+ };
381
+
382
+ const handleError = (event: SearchViewErrorEvent) => {
383
+ const { category, message, context } = event.nativeEvent;
384
+ // Full autocomplete for category values
385
+ if (category === 'image_load_failed') {
386
+ logger.warn(`Image failed to load: ${message}`, { context });
387
+ }
388
+ };
389
+ ```
390
+
391
+ ## Demo Apps & Examples
392
+
393
+ ### Official Demo App
394
+
395
+ **[expo-tvos-search-demo](https://github.com/keiver/expo-tvos-search-demo)** - Complete working examples with 7 different layout styles. Browse the [source code](https://github.com/keiver/expo-tvos-search-demo/tree/main/app/(tabs)) for each configuration.
396
+
397
+ ### Apps Using This Library
398
+
399
+ **[Tomo TV](https://github.com/keiver/tomotv)** - Full-featured tvOS Jellyfin client
400
+ - Real-world integration with media library API
401
+ - Advanced search with live server calls
402
+ - Complete authentication and navigation flow
403
+
404
+ ## See it in action:
405
+
406
+ <p align="center">
407
+ <img src="screenshots/expo-tvos-search.gif" width="700" alt="expo-tvos-search screen in action" loading="lazy" />
408
+ </p>
409
+
88
410
  ## Props
89
411
 
412
+ ### Core Props
413
+
90
414
  | Prop | Type | Default | Description |
91
415
  |------|------|---------|-------------|
92
416
  | `results` | `SearchResult[]` | `[]` | Array of search results |
93
417
  | `columns` | `number` | `5` | Number of columns in the grid |
94
- | `placeholder` | `string` | `"Search..."` | Search field placeholder |
418
+ | `placeholder` | `string` | `"Search movies and videos..."` | Search field placeholder |
95
419
  | `isLoading` | `boolean` | `false` | Shows loading indicator |
420
+
421
+ ### Card Dimensions & Spacing
422
+
423
+ | Prop | Type | Default | Description |
424
+ |------|------|---------|-------------|
425
+ | `cardWidth` | `number` | `280` | Width of each result card in points |
426
+ | `cardHeight` | `number` | `420` | Height of each result card in points |
427
+ | `cardMargin` | `number` | `40` | **(v1.3.0+)** Spacing between cards in the grid (horizontal and vertical) |
428
+ | `cardPadding` | `number` | `16` | **(v1.3.0+)** Padding inside the card for overlay content (title/subtitle) |
429
+ | `topInset` | `number` | `0` | Top padding (for tab bar clearance) |
430
+
431
+ ### Display Options
432
+
433
+ | Prop | Type | Default | Description |
434
+ |------|------|---------|-------------|
96
435
  | `showTitle` | `boolean` | `false` | Show title below each result |
97
436
  | `showSubtitle` | `boolean` | `false` | Show subtitle below title |
98
- | `showFocusBorder` | `boolean` | `false` | Show border on focused item |
99
- | `topInset` | `number` | `0` | Top padding (for tab bar clearance) |
100
437
  | `showTitleOverlay` | `boolean` | `true` | Show title overlay with gradient at bottom of card |
438
+ | `showFocusBorder` | `boolean` | `false` | Show border on focused item |
439
+ | `imageContentMode` | `'fill' \| 'fit' \| 'contain'` | `'fill'` | How images fill the card: `fill` (crop to fill), `fit`/`contain` (letterbox) |
440
+
441
+ ### Styling & Colors
442
+
443
+ | Prop | Type | Default | Description |
444
+ |------|------|---------|-------------|
445
+ | `textColor` | `string` | system default | Color for text and UI elements (hex format, e.g., "#FFFFFF") |
446
+ | `accentColor` | `string` | `"#FFC312"` | Accent color for focused elements (hex format, e.g., "#FFC312") |
447
+ | `overlayTitleSize` | `number` | `20` | **(v1.3.0+)** Font size for title text in the blur overlay (when showTitleOverlay is true) |
448
+
449
+ ### Animation
450
+
451
+ | Prop | Type | Default | Description |
452
+ |------|------|---------|-------------|
101
453
  | `enableMarquee` | `boolean` | `true` | Enable marquee scrolling for long titles |
102
454
  | `marqueeDelay` | `number` | `1.5` | Delay in seconds before marquee starts |
455
+
456
+ ### Text Customization
457
+
458
+ | Prop | Type | Default | Description |
459
+ |------|------|---------|-------------|
103
460
  | `emptyStateText` | `string` | `"Search for movies and videos"` | Text shown when search field is empty |
104
461
  | `searchingText` | `string` | `"Searching..."` | Text shown during search |
105
462
  | `noResultsText` | `string` | `"No results found"` | Text shown when no results found |
106
463
  | `noResultsHintText` | `string` | `"Try a different search term"` | Hint text below no results message |
464
+
465
+ ### Event Handlers
466
+
467
+ | Prop | Type | Default | Description |
468
+ |------|------|---------|-------------|
107
469
  | `onSearch` | `function` | required | Called when search text changes |
108
470
  | `onSelectItem` | `function` | required | Called when result is selected |
471
+ | `onError` | `function` | optional | **(v1.2.0+)** Called when errors occur (image loading failures, validation errors) |
472
+ | `onValidationWarning` | `function` | optional | **(v1.2.0+)** Called for non-fatal warnings (truncated fields, clamped values, invalid URLs) |
473
+
474
+ ### Other
475
+
476
+ | Prop | Type | Default | Description |
477
+ |------|------|---------|-------------|
478
+ | `style` | `ViewStyle` | optional | Style object for the view container |
109
479
 
110
480
  ## SearchResult Type
111
481
 
@@ -127,12 +497,70 @@ The native implementation applies the following validation and constraints:
127
497
  - **Image URL schemes**: Only HTTP and HTTPS URLs are accepted for `imageUrl`. Other URL schemes (e.g., `file://`, `data:`) are rejected.
128
498
  - **HTTPS recommended**: HTTP URLs may be blocked by App Transport Security on tvOS unless explicitly allowed in Info.plist.
129
499
 
130
- ## Requirements
500
+ ## Focus Handling - Do's and Don'ts
131
501
 
132
- - Node.js 18.0+
133
- - Expo SDK 51+
134
- - tvOS 15.0+
135
- - React Native TVOS
502
+ The native `.searchable` modifier manages focus automatically. Here's what to do and what to avoid:
503
+
504
+ ### Do: Render directly in your screen
505
+
506
+ ```tsx
507
+ function SearchScreen() {
508
+ return (
509
+ <TvosSearchView
510
+ results={results}
511
+ onSearch={handleSearch}
512
+ onSelectItem={handleSelect}
513
+ style={{ flex: 1 }}
514
+ />
515
+ );
516
+ }
517
+ ```
518
+
519
+ ### ❌ Don't: Wrap in focusable containers
520
+
521
+ ```tsx
522
+ // ❌ WRONG - breaks focus navigation
523
+ function SearchScreen() {
524
+ return (
525
+ <Pressable> {/* Don't wrap in Pressable */}
526
+ <TvosSearchView ... />
527
+ </Pressable>
528
+ );
529
+ }
530
+
531
+ // ❌ WRONG - interferes with native focus
532
+ function SearchScreen() {
533
+ return (
534
+ <TouchableOpacity> {/* Don't wrap in TouchableOpacity */}
535
+ <TvosSearchView ... />
536
+ </TouchableOpacity>
537
+ );
538
+ }
539
+ ```
540
+
541
+ **Why this breaks**: Focusable wrappers steal focus from the native SwiftUI search container, which breaks directional navigation.
542
+
543
+ ### ✅ Do: Use non-interactive containers
544
+
545
+ ```tsx
546
+ // ✅ CORRECT - View is not focusable
547
+ function SearchScreen() {
548
+ return (
549
+ <View style={{ flex: 1, backgroundColor: '#000' }}>
550
+ <TvosSearchView ... />
551
+ </View>
552
+ );
553
+ }
554
+
555
+ // ✅ CORRECT - SafeAreaView is not focusable
556
+ function SearchScreen() {
557
+ return (
558
+ <SafeAreaView style={{ flex: 1 }}>
559
+ <TvosSearchView ... />
560
+ </SafeAreaView>
561
+ );
562
+ }
563
+ ```
136
564
 
137
565
  ## Troubleshooting
138
566
 
@@ -146,6 +574,8 @@ EXPO_TV=1 npx expo prebuild --clean
146
574
  npx expo run:ios
147
575
  ```
148
576
 
577
+ **Note:** Expo Go doesn't support this. Build a dev client or native build instead.
578
+
149
579
  ### Images not loading
150
580
 
151
581
  1. Verify your image URLs are HTTPS (HTTP may be blocked by App Transport Security)
@@ -183,6 +613,19 @@ Tests cover:
183
613
  - Component rendering when native module is unavailable
184
614
  - Event structure validation
185
615
 
616
+ ## Contributing
617
+
618
+ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
619
+ - Code of conduct
620
+ - Development setup
621
+ - Testing requirements
622
+ - Commit message conventions
623
+ - Pull request process
624
+
625
+ ### Adding New Props
626
+
627
+ 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.
628
+
186
629
  ## License
187
630
 
188
631
  MIT