expo-tvos-search 1.2.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/LICENSE +21 -0
- package/README.md +197 -0
- package/build/index.d.ts +215 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +81 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoTvosSearch.podspec +16 -0
- package/ios/ExpoTvosSearchModule.swift +84 -0
- package/ios/ExpoTvosSearchView.swift +507 -0
- package/ios/MarqueeAnimationCalculator.swift +41 -0
- package/ios/MarqueeText.swift +168 -0
- package/ios/Tests/MarqueeAnimationCalculatorTests.swift +103 -0
- package/ios/Tests/SearchResultItemTests.swift +200 -0
- package/ios/Tests/SearchViewModelTests.swift +202 -0
- package/package.json +73 -0
- package/src/__tests__/__mocks__/expo-modules-core.ts +21 -0
- package/src/__tests__/__mocks__/react-native.ts +41 -0
- package/src/__tests__/events.test.ts +126 -0
- package/src/__tests__/index.test.tsx +124 -0
- package/src/__tests__/setup.ts +49 -0
- package/src/index.tsx +261 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Keiver Hernandez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# expo-tvos-search
|
|
2
|
+
|
|
3
|
+
A native tvOS search component for Expo and React Native.
|
|
4
|
+
|
|
5
|
+
This library provides a native tvOS search view using SwiftUI's `.searchable` modifier. It handles focus, keyboard navigation, and accessibility out of the box, providing a seamless search experience on Apple TV with a native fullscreen search interface.
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="screenshots/results.png" width="700" alt="TomoTV Search Results"/>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<table>
|
|
12
|
+
<tr>
|
|
13
|
+
<td align="center">
|
|
14
|
+
<img src="screenshots/default.png" width="280" alt="Search"/><br/>
|
|
15
|
+
<sub>Native Search</sub>
|
|
16
|
+
</td>
|
|
17
|
+
<td align="center">
|
|
18
|
+
<img src="screenshots/results.png" width="280" alt="Results"/><br/>
|
|
19
|
+
<sub>Results</sub>
|
|
20
|
+
</td>
|
|
21
|
+
<td align="center">
|
|
22
|
+
<img src="screenshots/no-results.png" width="280" alt="No Results"/><br/>
|
|
23
|
+
<sub>Empty State</sub>
|
|
24
|
+
</td>
|
|
25
|
+
</tr>
|
|
26
|
+
</table>
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install github:keiver/expo-tvos-search
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then rebuild your native project:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
EXPO_TV=1 npx expo prebuild --clean
|
|
39
|
+
npx expo run:ios
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { TvosSearchView, isNativeSearchAvailable } from 'expo-tvos-search';
|
|
46
|
+
|
|
47
|
+
function SearchScreen() {
|
|
48
|
+
const [results, setResults] = useState([]);
|
|
49
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
50
|
+
|
|
51
|
+
const handleSearch = (event) => {
|
|
52
|
+
const query = event.nativeEvent.query;
|
|
53
|
+
// Fetch your results...
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleSelect = (event) => {
|
|
57
|
+
const id = event.nativeEvent.id;
|
|
58
|
+
// Navigate to detail...
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!isNativeSearchAvailable()) {
|
|
62
|
+
return <YourFallbackSearch />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<TvosSearchView
|
|
67
|
+
results={results}
|
|
68
|
+
columns={5}
|
|
69
|
+
placeholder="Search..."
|
|
70
|
+
isLoading={isLoading}
|
|
71
|
+
topInset={140}
|
|
72
|
+
onSearch={handleSearch}
|
|
73
|
+
onSelectItem={handleSelect}
|
|
74
|
+
style={{ flex: 1 }}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Props
|
|
81
|
+
|
|
82
|
+
| Prop | Type | Default | Description |
|
|
83
|
+
|------|------|---------|-------------|
|
|
84
|
+
| `results` | `SearchResult[]` | `[]` | Array of search results |
|
|
85
|
+
| `columns` | `number` | `5` | Number of columns in the grid |
|
|
86
|
+
| `placeholder` | `string` | `"Search..."` | Search field placeholder |
|
|
87
|
+
| `isLoading` | `boolean` | `false` | Shows loading indicator |
|
|
88
|
+
| `showTitle` | `boolean` | `false` | Show title below each result |
|
|
89
|
+
| `showSubtitle` | `boolean` | `false` | Show subtitle below title |
|
|
90
|
+
| `showFocusBorder` | `boolean` | `false` | Show border on focused item |
|
|
91
|
+
| `topInset` | `number` | `0` | Top padding (for tab bar clearance) |
|
|
92
|
+
| `showTitleOverlay` | `boolean` | `true` | Show title overlay with gradient at bottom of card |
|
|
93
|
+
| `enableMarquee` | `boolean` | `true` | Enable marquee scrolling for long titles |
|
|
94
|
+
| `marqueeDelay` | `number` | `1.5` | Delay in seconds before marquee starts |
|
|
95
|
+
| `onSearch` | `function` | required | Called when search text changes |
|
|
96
|
+
| `onSelectItem` | `function` | required | Called when result is selected |
|
|
97
|
+
|
|
98
|
+
## SearchResult Type
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
interface SearchResult {
|
|
102
|
+
id: string;
|
|
103
|
+
title: string;
|
|
104
|
+
subtitle?: string;
|
|
105
|
+
imageUrl?: string;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Requirements
|
|
110
|
+
|
|
111
|
+
- Expo SDK 51+
|
|
112
|
+
- tvOS 15.0+
|
|
113
|
+
- React Native TVOS
|
|
114
|
+
|
|
115
|
+
## Troubleshooting
|
|
116
|
+
|
|
117
|
+
### Native module not found
|
|
118
|
+
|
|
119
|
+
If you see `requireNativeViewManager("ExpoTvosSearch") returned null`, the native module hasn't been built:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Clean and rebuild with tvOS support
|
|
123
|
+
EXPO_TV=1 npx expo prebuild --clean
|
|
124
|
+
npx expo run:ios
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Images not loading
|
|
128
|
+
|
|
129
|
+
1. Verify your image URLs are HTTPS (HTTP may be blocked by App Transport Security)
|
|
130
|
+
2. Check that the Jellyfin API key is included in the URL query parameters
|
|
131
|
+
3. For local development, ensure your server is accessible from the Apple TV
|
|
132
|
+
|
|
133
|
+
### Focus issues
|
|
134
|
+
|
|
135
|
+
If focus doesn't move correctly:
|
|
136
|
+
|
|
137
|
+
1. Ensure `columns` prop matches your layout (default: 5)
|
|
138
|
+
2. Check `topInset` if the first row is hidden under the tab bar
|
|
139
|
+
3. The native `.searchable` modifier handles focus automatically - avoid wrapping in focusable containers
|
|
140
|
+
|
|
141
|
+
### Marquee not scrolling
|
|
142
|
+
|
|
143
|
+
If long titles don't scroll when focused:
|
|
144
|
+
|
|
145
|
+
1. Verify `enableMarquee={true}` (default)
|
|
146
|
+
2. Check `marqueeDelay` - scrolling starts after this delay (default: 1.5s)
|
|
147
|
+
3. Text only scrolls if it overflows the card width
|
|
148
|
+
|
|
149
|
+
## Testing
|
|
150
|
+
|
|
151
|
+
Run TypeScript tests:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
npm test # Run tests once
|
|
155
|
+
npm run test:watch # Watch mode
|
|
156
|
+
npm run test:coverage # Generate coverage report
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Tests cover:
|
|
160
|
+
- `isNativeSearchAvailable()` behavior on different platforms
|
|
161
|
+
- Component rendering when native module is unavailable
|
|
162
|
+
- Event structure validation
|
|
163
|
+
|
|
164
|
+
### Best Practices
|
|
165
|
+
|
|
166
|
+
For optimal performance:
|
|
167
|
+
- **Debounce search input**: Wait 300-500ms after typing stops before calling your API
|
|
168
|
+
- **Batch result updates**: Update the entire `results` array at once rather than incrementally
|
|
169
|
+
- **Limit image sizes**: Use appropriately sized poster images (280x420 is the card size)
|
|
170
|
+
- **Cap result count**: Consider limiting to 50-100 results for smooth scrolling
|
|
171
|
+
|
|
172
|
+
## Accessibility
|
|
173
|
+
|
|
174
|
+
### Built-in Support
|
|
175
|
+
|
|
176
|
+
The native SwiftUI implementation provides accessibility features automatically:
|
|
177
|
+
- **Focus management**: tvOS focus system handles navigation
|
|
178
|
+
- **VoiceOver**: Cards announce title and subtitle
|
|
179
|
+
- **Button semantics**: Cards are properly identified as interactive elements
|
|
180
|
+
- **Focus indicators**: Visual feedback for focused state
|
|
181
|
+
|
|
182
|
+
### Remote Navigation
|
|
183
|
+
|
|
184
|
+
The native `.searchable` modifier provides standard tvOS navigation:
|
|
185
|
+
- **Swipe up/down**: Move between search field and results
|
|
186
|
+
- **Swipe left/right**: Navigate between grid items
|
|
187
|
+
- **Click (select)**: Open the focused result
|
|
188
|
+
- **Menu button**: Exit search or navigate back
|
|
189
|
+
|
|
190
|
+
Built for [TomoTV](https://github.com/keiver/tomotv), a Jellyfin client for Apple TV.
|
|
191
|
+
|
|
192
|
+
Swift documentation references:
|
|
193
|
+
- [.searchable modifier](https://developer.apple.com/documentation/SwiftUI/Creating-a-tvOS-media-catalog-app-in-SwiftUI)
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ViewStyle } from "react-native";
|
|
3
|
+
/**
|
|
4
|
+
* Event payload for search text changes.
|
|
5
|
+
* Fired when the user types in the native search field.
|
|
6
|
+
*/
|
|
7
|
+
export interface SearchEvent {
|
|
8
|
+
nativeEvent: {
|
|
9
|
+
/** The current search query string entered by the user */
|
|
10
|
+
query: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Event payload for item selection.
|
|
15
|
+
* Fired when the user selects a search result.
|
|
16
|
+
*/
|
|
17
|
+
export interface SelectItemEvent {
|
|
18
|
+
nativeEvent: {
|
|
19
|
+
/** The unique identifier of the selected search result */
|
|
20
|
+
id: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Represents a single search result displayed in the grid.
|
|
25
|
+
*/
|
|
26
|
+
export interface SearchResult {
|
|
27
|
+
/** Unique identifier for the result (used in onSelectItem callback) */
|
|
28
|
+
id: string;
|
|
29
|
+
/** Primary display text for the result */
|
|
30
|
+
title: string;
|
|
31
|
+
/** Optional secondary text displayed below the title */
|
|
32
|
+
subtitle?: string;
|
|
33
|
+
/** Optional image URL for the result poster/thumbnail */
|
|
34
|
+
imageUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Props for the TvosSearchView component.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <TvosSearchView
|
|
42
|
+
* results={searchResults}
|
|
43
|
+
* columns={5}
|
|
44
|
+
* placeholder="Search movies..."
|
|
45
|
+
* isLoading={loading}
|
|
46
|
+
* topInset={140}
|
|
47
|
+
* onSearch={(e) => handleSearch(e.nativeEvent.query)}
|
|
48
|
+
* onSelectItem={(e) => navigateTo(e.nativeEvent.id)}
|
|
49
|
+
* style={{ flex: 1 }}
|
|
50
|
+
* />
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export interface TvosSearchViewProps {
|
|
54
|
+
/**
|
|
55
|
+
* Array of search results to display in the grid.
|
|
56
|
+
* Each result should have a unique `id`.
|
|
57
|
+
* Arrays larger than 500 items are truncated.
|
|
58
|
+
* Results with empty `id` or `title` are skipped.
|
|
59
|
+
* @maximum 500
|
|
60
|
+
*/
|
|
61
|
+
results: SearchResult[];
|
|
62
|
+
/**
|
|
63
|
+
* Number of columns in the results grid.
|
|
64
|
+
* Values outside 1-10 range are clamped.
|
|
65
|
+
* @default 5
|
|
66
|
+
* @minimum 1
|
|
67
|
+
* @maximum 10
|
|
68
|
+
*/
|
|
69
|
+
columns?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Placeholder text shown in the search field when empty.
|
|
72
|
+
* @default "Search..."
|
|
73
|
+
*/
|
|
74
|
+
placeholder?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Whether to show a loading indicator.
|
|
77
|
+
* @default false
|
|
78
|
+
*/
|
|
79
|
+
isLoading?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Show title text below each result card.
|
|
82
|
+
* @default false
|
|
83
|
+
*/
|
|
84
|
+
showTitle?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Show subtitle text below title.
|
|
87
|
+
* Requires `showTitle` to be true to be visible.
|
|
88
|
+
* @default false
|
|
89
|
+
*/
|
|
90
|
+
showSubtitle?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Show gold border on focused card.
|
|
93
|
+
* @default false
|
|
94
|
+
*/
|
|
95
|
+
showFocusBorder?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Extra top padding in points for tab bar clearance.
|
|
98
|
+
* Useful when the view is displayed under a navigation bar.
|
|
99
|
+
* Values outside 0-500 range are clamped.
|
|
100
|
+
* @default 0
|
|
101
|
+
* @minimum 0
|
|
102
|
+
* @maximum 500
|
|
103
|
+
*/
|
|
104
|
+
topInset?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Show title overlay with gradient at bottom of card.
|
|
107
|
+
* This displays the title on top of the image.
|
|
108
|
+
* @default true
|
|
109
|
+
*/
|
|
110
|
+
showTitleOverlay?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Enable marquee scrolling for long titles that overflow the card width.
|
|
113
|
+
* @default true
|
|
114
|
+
*/
|
|
115
|
+
enableMarquee?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Delay in seconds before marquee starts scrolling when item is focused.
|
|
118
|
+
* Values outside 0-60 range are clamped.
|
|
119
|
+
* @default 1.5
|
|
120
|
+
* @minimum 0
|
|
121
|
+
* @maximum 60
|
|
122
|
+
*/
|
|
123
|
+
marqueeDelay?: number;
|
|
124
|
+
/**
|
|
125
|
+
* Text displayed when the search field is empty and no results are shown.
|
|
126
|
+
* @default "Search for movies and videos"
|
|
127
|
+
*/
|
|
128
|
+
emptyStateText?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Text displayed while searching (when loading with no results yet).
|
|
131
|
+
* @default "Searching..."
|
|
132
|
+
*/
|
|
133
|
+
searchingText?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Text displayed when search returns no results.
|
|
136
|
+
* @default "No results found"
|
|
137
|
+
*/
|
|
138
|
+
noResultsText?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Hint text displayed below the no results message.
|
|
141
|
+
* @default "Try a different search term"
|
|
142
|
+
*/
|
|
143
|
+
noResultsHintText?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Callback fired when the search text changes.
|
|
146
|
+
* Debounce this handler to avoid excessive API calls.
|
|
147
|
+
*/
|
|
148
|
+
onSearch: (event: SearchEvent) => void;
|
|
149
|
+
/**
|
|
150
|
+
* Callback fired when a search result is selected.
|
|
151
|
+
* Use the `id` from the event to identify which result was selected.
|
|
152
|
+
*/
|
|
153
|
+
onSelectItem: (event: SelectItemEvent) => void;
|
|
154
|
+
/**
|
|
155
|
+
* Optional style for the view container.
|
|
156
|
+
*/
|
|
157
|
+
style?: ViewStyle;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Native tvOS search view component using SwiftUI's `.searchable` modifier.
|
|
161
|
+
*
|
|
162
|
+
* This component provides a native search experience on tvOS with proper focus
|
|
163
|
+
* handling and keyboard navigation. On non-tvOS platforms or when the native
|
|
164
|
+
* module is unavailable, it renders `null` - use `isNativeSearchAvailable()`
|
|
165
|
+
* to check availability and render a fallback.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```tsx
|
|
169
|
+
* import { TvosSearchView, isNativeSearchAvailable } from 'expo-tvos-search';
|
|
170
|
+
*
|
|
171
|
+
* function SearchScreen() {
|
|
172
|
+
* const [results, setResults] = useState<SearchResult[]>([]);
|
|
173
|
+
*
|
|
174
|
+
* if (!isNativeSearchAvailable()) {
|
|
175
|
+
* return <FallbackSearchComponent />;
|
|
176
|
+
* }
|
|
177
|
+
*
|
|
178
|
+
* return (
|
|
179
|
+
* <TvosSearchView
|
|
180
|
+
* results={results}
|
|
181
|
+
* onSearch={(e) => fetchResults(e.nativeEvent.query)}
|
|
182
|
+
* onSelectItem={(e) => router.push(`/detail/${e.nativeEvent.id}`)}
|
|
183
|
+
* style={{ flex: 1 }}
|
|
184
|
+
* />
|
|
185
|
+
* );
|
|
186
|
+
* }
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* @param props - Component props
|
|
190
|
+
* @returns The native search view on tvOS, or `null` if unavailable
|
|
191
|
+
*/
|
|
192
|
+
export declare function TvosSearchView(props: TvosSearchViewProps): React.JSX.Element | null;
|
|
193
|
+
/**
|
|
194
|
+
* Checks if the native tvOS search component is available.
|
|
195
|
+
*
|
|
196
|
+
* Returns `true` only when:
|
|
197
|
+
* - Running on tvOS (Platform.OS === "ios" && Platform.isTV)
|
|
198
|
+
* - The native module has been built (via `expo prebuild`)
|
|
199
|
+
* - expo-modules-core is properly installed
|
|
200
|
+
*
|
|
201
|
+
* Use this to conditionally render a fallback search implementation
|
|
202
|
+
* on non-tvOS platforms or when the native module is unavailable.
|
|
203
|
+
*
|
|
204
|
+
* @returns `true` if TvosSearchView will render, `false` if it will return null
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```tsx
|
|
208
|
+
* if (!isNativeSearchAvailable()) {
|
|
209
|
+
* return <ReactNativeSearchFallback />;
|
|
210
|
+
* }
|
|
211
|
+
* return <TvosSearchView {...props} />;
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
export declare function isNativeSearchAvailable(): boolean;
|
|
215
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAY,MAAM,cAAc,CAAC;AAEnD;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE;QACX,0DAA0D;QAC1D,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE;QACX,0DAA0D;QAC1D,EAAE,EAAE,MAAM,CAAC;KACZ,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,OAAO,EAAE,YAAY,EAAE,CAAC;IAExB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IAEpB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IAExB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B;;;OAGG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IAEvC;;;OAGG;IACH,YAAY,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAE/C;;OAEG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,4BAKxD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,IAAI,OAAO,CAEjD"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
// Safely try to load the native view - it may not be available if:
|
|
4
|
+
// 1. Running on a non-tvOS platform
|
|
5
|
+
// 2. Native module hasn't been built yet (needs expo prebuild)
|
|
6
|
+
// 3. expo-modules-core isn't properly installed
|
|
7
|
+
let NativeView = null;
|
|
8
|
+
if (Platform.OS === "ios" && Platform.isTV) {
|
|
9
|
+
try {
|
|
10
|
+
const { requireNativeViewManager } = require("expo-modules-core");
|
|
11
|
+
if (typeof requireNativeViewManager === "function") {
|
|
12
|
+
NativeView = requireNativeViewManager("ExpoTvosSearch");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Native module not available - will fall back to React Native implementation
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Native tvOS search view component using SwiftUI's `.searchable` modifier.
|
|
21
|
+
*
|
|
22
|
+
* This component provides a native search experience on tvOS with proper focus
|
|
23
|
+
* handling and keyboard navigation. On non-tvOS platforms or when the native
|
|
24
|
+
* module is unavailable, it renders `null` - use `isNativeSearchAvailable()`
|
|
25
|
+
* to check availability and render a fallback.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* import { TvosSearchView, isNativeSearchAvailable } from 'expo-tvos-search';
|
|
30
|
+
*
|
|
31
|
+
* function SearchScreen() {
|
|
32
|
+
* const [results, setResults] = useState<SearchResult[]>([]);
|
|
33
|
+
*
|
|
34
|
+
* if (!isNativeSearchAvailable()) {
|
|
35
|
+
* return <FallbackSearchComponent />;
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* return (
|
|
39
|
+
* <TvosSearchView
|
|
40
|
+
* results={results}
|
|
41
|
+
* onSearch={(e) => fetchResults(e.nativeEvent.query)}
|
|
42
|
+
* onSelectItem={(e) => router.push(`/detail/${e.nativeEvent.id}`)}
|
|
43
|
+
* style={{ flex: 1 }}
|
|
44
|
+
* />
|
|
45
|
+
* );
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @param props - Component props
|
|
50
|
+
* @returns The native search view on tvOS, or `null` if unavailable
|
|
51
|
+
*/
|
|
52
|
+
export function TvosSearchView(props) {
|
|
53
|
+
if (!NativeView) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return React.createElement(NativeView, { ...props });
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Checks if the native tvOS search component is available.
|
|
60
|
+
*
|
|
61
|
+
* Returns `true` only when:
|
|
62
|
+
* - Running on tvOS (Platform.OS === "ios" && Platform.isTV)
|
|
63
|
+
* - The native module has been built (via `expo prebuild`)
|
|
64
|
+
* - expo-modules-core is properly installed
|
|
65
|
+
*
|
|
66
|
+
* Use this to conditionally render a fallback search implementation
|
|
67
|
+
* on non-tvOS platforms or when the native module is unavailable.
|
|
68
|
+
*
|
|
69
|
+
* @returns `true` if TvosSearchView will render, `false` if it will return null
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```tsx
|
|
73
|
+
* if (!isNativeSearchAvailable()) {
|
|
74
|
+
* return <ReactNativeSearchFallback />;
|
|
75
|
+
* }
|
|
76
|
+
* return <TvosSearchView {...props} />;
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function isNativeSearchAvailable() {
|
|
80
|
+
return NativeView !== null;
|
|
81
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'ExpoTvosSearch'
|
|
3
|
+
s.version = '1.0.0'
|
|
4
|
+
s.summary = 'Native tvOS search view with SwiftUI .searchable modifier'
|
|
5
|
+
s.description = 'Provides a native tvOS search experience using SwiftUI searchable modifier for proper focus and keyboard navigation'
|
|
6
|
+
s.author = 'Keiver Hernandez'
|
|
7
|
+
s.homepage = 'https://github.com/keiver/expo-tvos-search'
|
|
8
|
+
s.license = { :type => 'MIT', :file => '../LICENSE' }
|
|
9
|
+
s.source = { :git => 'https://github.com/keiver/expo-tvos-search.git', :tag => s.version.to_s }
|
|
10
|
+
|
|
11
|
+
s.platforms = { :ios => '15.1', :tvos => '15.0' }
|
|
12
|
+
s.swift_version = '5.9'
|
|
13
|
+
s.source_files = '*.swift'
|
|
14
|
+
|
|
15
|
+
s.dependency 'ExpoModulesCore'
|
|
16
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class ExpoTvosSearchModule: Module {
|
|
4
|
+
// Validation constants
|
|
5
|
+
private static let minColumns = 1
|
|
6
|
+
private static let maxColumns = 10
|
|
7
|
+
private static let maxResults = 500
|
|
8
|
+
private static let maxMarqueeDelay: Double = 60.0
|
|
9
|
+
private static let maxStringLength = 500
|
|
10
|
+
|
|
11
|
+
public func definition() -> ModuleDefinition {
|
|
12
|
+
Name("ExpoTvosSearch")
|
|
13
|
+
|
|
14
|
+
View(ExpoTvosSearchView.self) {
|
|
15
|
+
Events("onSearch", "onSelectItem")
|
|
16
|
+
|
|
17
|
+
Prop("results") { (view: ExpoTvosSearchView, results: [[String: Any]]) in
|
|
18
|
+
// Limit results array size to prevent memory issues
|
|
19
|
+
let limitedResults = Array(results.prefix(Self.maxResults))
|
|
20
|
+
view.updateResults(limitedResults)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Prop("columns") { (view: ExpoTvosSearchView, columns: Int) in
|
|
24
|
+
// Clamp columns between min and max for safe grid layout
|
|
25
|
+
view.columns = min(max(Self.minColumns, columns), Self.maxColumns)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Prop("placeholder") { (view: ExpoTvosSearchView, placeholder: String) in
|
|
29
|
+
// Limit placeholder length to prevent layout issues
|
|
30
|
+
view.placeholder = String(placeholder.prefix(Self.maxStringLength))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Prop("isLoading") { (view: ExpoTvosSearchView, isLoading: Bool) in
|
|
34
|
+
view.isLoading = isLoading
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Prop("showTitle") { (view: ExpoTvosSearchView, showTitle: Bool) in
|
|
38
|
+
view.showTitle = showTitle
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Prop("showSubtitle") { (view: ExpoTvosSearchView, showSubtitle: Bool) in
|
|
42
|
+
view.showSubtitle = showSubtitle
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Prop("showFocusBorder") { (view: ExpoTvosSearchView, showFocusBorder: Bool) in
|
|
46
|
+
view.showFocusBorder = showFocusBorder
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Prop("topInset") { (view: ExpoTvosSearchView, topInset: Double) in
|
|
50
|
+
// Clamp to non-negative values (max 500 points reasonable for any screen)
|
|
51
|
+
view.topInset = CGFloat(min(max(0, topInset), 500))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Prop("showTitleOverlay") { (view: ExpoTvosSearchView, show: Bool) in
|
|
55
|
+
view.showTitleOverlay = show
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
Prop("enableMarquee") { (view: ExpoTvosSearchView, enable: Bool) in
|
|
59
|
+
view.enableMarquee = enable
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
Prop("marqueeDelay") { (view: ExpoTvosSearchView, delay: Double) in
|
|
63
|
+
// Clamp between 0 and maxMarqueeDelay seconds
|
|
64
|
+
view.marqueeDelay = min(max(0, delay), Self.maxMarqueeDelay)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Prop("emptyStateText") { (view: ExpoTvosSearchView, text: String) in
|
|
68
|
+
view.emptyStateText = String(text.prefix(Self.maxStringLength))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Prop("searchingText") { (view: ExpoTvosSearchView, text: String) in
|
|
72
|
+
view.searchingText = String(text.prefix(Self.maxStringLength))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
Prop("noResultsText") { (view: ExpoTvosSearchView, text: String) in
|
|
76
|
+
view.noResultsText = String(text.prefix(Self.maxStringLength))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Prop("noResultsHintText") { (view: ExpoTvosSearchView, text: String) in
|
|
80
|
+
view.noResultsHintText = String(text.prefix(Self.maxStringLength))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|