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/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "expo-tvos-search",
3
+ "version": "1.2.0",
4
+ "description": "Native tvOS search view using SwiftUI .searchable modifier for Expo/React Native",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/keiver/expo-tvos-search.git"
10
+ },
11
+ "homepage": "https://github.com/keiver/expo-tvos-search#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/keiver/expo-tvos-search/issues"
14
+ },
15
+ "author": {
16
+ "name": "Keiver Hernandez",
17
+ "url": "https://github.com/keiver"
18
+ },
19
+ "license": "MIT",
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "clean": "rm -rf build",
23
+ "prepublishOnly": "npm run clean && npm run build",
24
+ "test": "jest",
25
+ "test:watch": "jest --watch",
26
+ "test:coverage": "jest --coverage"
27
+ },
28
+ "keywords": [
29
+ "expo",
30
+ "expo-module",
31
+ "tvos",
32
+ "appletv",
33
+ "apple-tv",
34
+ "search",
35
+ "swiftui",
36
+ "searchable",
37
+ "react-native",
38
+ "react-native-tvos"
39
+ ],
40
+ "peerDependencies": {
41
+ "expo": ">=51.0.0",
42
+ "expo-modules-core": ">=1.0.0",
43
+ "react": ">=18.0.0",
44
+ "react-native": "*"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "react-native": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@types/jest": "^29.5.12",
53
+ "@types/react": "^18.2.0",
54
+ "@types/react-native": "^0.72.0",
55
+ "expo-modules-core": "~3.0.25",
56
+ "jest": "^29.7.0",
57
+ "react": "^18.2.0",
58
+ "react-native": "^0.74.0",
59
+ "ts-jest": "^29.4.6",
60
+ "typescript": "~5.3.0"
61
+ },
62
+ "files": [
63
+ "build",
64
+ "src",
65
+ "ios",
66
+ "expo-module.config.json",
67
+ "README.md",
68
+ "LICENSE"
69
+ ],
70
+ "engines": {
71
+ "node": ">=18.0.0"
72
+ }
73
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mock expo-modules-core module for testing
3
+ * Uses global state to persist mock values across module resets
4
+ */
5
+
6
+ declare global {
7
+ var __mockNativeViewAvailable: boolean;
8
+ }
9
+
10
+ // Only initialize if not already set (allows persistence across module resets)
11
+ if (globalThis.__mockNativeViewAvailable === undefined) {
12
+ globalThis.__mockNativeViewAvailable = false;
13
+ }
14
+
15
+ export const requireNativeViewManager = (_name: string) => {
16
+ if (globalThis.__mockNativeViewAvailable) {
17
+ // Return a mock component function
18
+ return () => null;
19
+ }
20
+ return null;
21
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Mock react-native module for testing
3
+ * Uses global state to persist mock values across module resets
4
+ */
5
+
6
+ // Global state for Platform mock - only initialize if not already set
7
+ declare global {
8
+ var __mockPlatformOS: 'ios' | 'android' | 'web';
9
+ var __mockPlatformIsTV: boolean;
10
+ }
11
+
12
+ // Only initialize if not already set (allows persistence across module resets)
13
+ if (globalThis.__mockPlatformOS === undefined) {
14
+ globalThis.__mockPlatformOS = 'web';
15
+ }
16
+ if (globalThis.__mockPlatformIsTV === undefined) {
17
+ globalThis.__mockPlatformIsTV = false;
18
+ }
19
+
20
+ export const Platform = {
21
+ get OS() {
22
+ return globalThis.__mockPlatformOS;
23
+ },
24
+ set OS(value: 'ios' | 'android' | 'web') {
25
+ globalThis.__mockPlatformOS = value;
26
+ },
27
+ get isTV() {
28
+ return globalThis.__mockPlatformIsTV;
29
+ },
30
+ set isTV(value: boolean) {
31
+ globalThis.__mockPlatformIsTV = value;
32
+ },
33
+ select: <T>(options: { ios?: T; android?: T; web?: T; default?: T }): T | undefined => {
34
+ return options.default ?? options.web;
35
+ },
36
+ };
37
+
38
+ export interface ViewStyle {
39
+ flex?: number;
40
+ [key: string]: unknown;
41
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tests for event structure validation
3
+ *
4
+ * These tests verify that event handlers receive correctly structured events
5
+ * matching the native Swift implementation in ExpoTvosSearchView.swift
6
+ */
7
+
8
+ describe('onSearch event structure', () => {
9
+ it('provides query in nativeEvent', () => {
10
+ const mockHandler = jest.fn();
11
+ const event = { nativeEvent: { query: 'test search' } };
12
+
13
+ mockHandler(event);
14
+
15
+ expect(mockHandler).toHaveBeenCalledWith({
16
+ nativeEvent: { query: 'test search' },
17
+ });
18
+ expect(mockHandler.mock.calls[0][0].nativeEvent.query).toBe('test search');
19
+ });
20
+
21
+ it('handles empty query string', () => {
22
+ const mockHandler = jest.fn();
23
+ const event = { nativeEvent: { query: '' } };
24
+
25
+ mockHandler(event);
26
+
27
+ expect(mockHandler.mock.calls[0][0].nativeEvent.query).toBe('');
28
+ });
29
+
30
+ it('handles special characters in query', () => {
31
+ const mockHandler = jest.fn();
32
+ const specialQueries = [
33
+ 'The Matrix: Reloaded',
34
+ "Ocean's Eleven",
35
+ 'Amélie',
36
+ '日本語タイトル',
37
+ 'Film & TV',
38
+ 'Movie (2023)',
39
+ '50% Off',
40
+ ];
41
+
42
+ specialQueries.forEach((query) => {
43
+ mockHandler({ nativeEvent: { query } });
44
+ });
45
+
46
+ expect(mockHandler).toHaveBeenCalledTimes(specialQueries.length);
47
+ specialQueries.forEach((query, index) => {
48
+ expect(mockHandler.mock.calls[index][0].nativeEvent.query).toBe(query);
49
+ });
50
+ });
51
+
52
+ it('handles whitespace in query', () => {
53
+ const mockHandler = jest.fn();
54
+ const queries = [' leading', 'trailing ', ' both ', 'multiple spaces'];
55
+
56
+ queries.forEach((query) => {
57
+ mockHandler({ nativeEvent: { query } });
58
+ });
59
+
60
+ expect(mockHandler).toHaveBeenCalledTimes(queries.length);
61
+ });
62
+ });
63
+
64
+ describe('onSelectItem event structure', () => {
65
+ it('provides id in nativeEvent', () => {
66
+ const mockHandler = jest.fn();
67
+ const event = { nativeEvent: { id: 'item-123' } };
68
+
69
+ mockHandler(event);
70
+
71
+ expect(mockHandler).toHaveBeenCalledWith({
72
+ nativeEvent: { id: 'item-123' },
73
+ });
74
+ expect(mockHandler.mock.calls[0][0].nativeEvent.id).toBe('item-123');
75
+ });
76
+
77
+ it('handles various id formats', () => {
78
+ const mockHandler = jest.fn();
79
+ const ids = [
80
+ 'simple-id',
81
+ '12345',
82
+ 'uuid-a1b2c3d4-e5f6-7890',
83
+ 'jellyfin/Items/abc123',
84
+ 'item:with:colons',
85
+ ];
86
+
87
+ ids.forEach((id) => {
88
+ mockHandler({ nativeEvent: { id } });
89
+ });
90
+
91
+ expect(mockHandler).toHaveBeenCalledTimes(ids.length);
92
+ ids.forEach((id, index) => {
93
+ expect(mockHandler.mock.calls[index][0].nativeEvent.id).toBe(id);
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('event handler integration', () => {
99
+ it('simulates full search flow', () => {
100
+ const onSearch = jest.fn();
101
+ const onSelectItem = jest.fn();
102
+
103
+ // User types search query
104
+ onSearch({ nativeEvent: { query: 'matrix' } });
105
+ expect(onSearch).toHaveBeenCalledTimes(1);
106
+
107
+ // User refines search
108
+ onSearch({ nativeEvent: { query: 'matrix reloaded' } });
109
+ expect(onSearch).toHaveBeenCalledTimes(2);
110
+
111
+ // User selects a result
112
+ onSelectItem({ nativeEvent: { id: 'movie-456' } });
113
+ expect(onSelectItem).toHaveBeenCalledTimes(1);
114
+ expect(onSelectItem.mock.calls[0][0].nativeEvent.id).toBe('movie-456');
115
+ });
116
+
117
+ it('simulates clearing search', () => {
118
+ const onSearch = jest.fn();
119
+
120
+ onSearch({ nativeEvent: { query: 'test' } });
121
+ onSearch({ nativeEvent: { query: '' } });
122
+
123
+ expect(onSearch).toHaveBeenCalledTimes(2);
124
+ expect(onSearch.mock.calls[1][0].nativeEvent.query).toBe('');
125
+ });
126
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tests for expo-tvos-search TypeScript exports
3
+ *
4
+ * Note: Platform checks in index.tsx run at module load time.
5
+ * These tests verify the behavior when mocked Platform values are set
6
+ * before the module is required.
7
+ */
8
+
9
+ import {
10
+ mockTvOSPlatform,
11
+ mockWebPlatform,
12
+ mockNativeModuleAvailable,
13
+ mockNativeModuleUnavailable,
14
+ } from './setup';
15
+
16
+ describe('isNativeSearchAvailable', () => {
17
+ describe('on non-tvOS platforms', () => {
18
+ beforeEach(() => {
19
+ jest.resetModules();
20
+ mockWebPlatform();
21
+ mockNativeModuleUnavailable();
22
+ });
23
+
24
+ it('returns false when not on tvOS', () => {
25
+ const { isNativeSearchAvailable } = require('../index');
26
+ expect(isNativeSearchAvailable()).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe('on tvOS without native module', () => {
31
+ beforeEach(() => {
32
+ jest.resetModules();
33
+ mockTvOSPlatform();
34
+ mockNativeModuleUnavailable();
35
+ });
36
+
37
+ it('returns false when native module unavailable', () => {
38
+ const { isNativeSearchAvailable } = require('../index');
39
+ expect(isNativeSearchAvailable()).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('on tvOS with native module', () => {
44
+ beforeEach(() => {
45
+ jest.resetModules();
46
+ mockTvOSPlatform();
47
+ mockNativeModuleAvailable();
48
+ });
49
+
50
+ it('returns true when native module is available', () => {
51
+ const { isNativeSearchAvailable } = require('../index');
52
+ expect(isNativeSearchAvailable()).toBe(true);
53
+ });
54
+ });
55
+ });
56
+
57
+ describe('TvosSearchView', () => {
58
+ beforeEach(() => {
59
+ jest.resetModules();
60
+ mockWebPlatform();
61
+ mockNativeModuleUnavailable();
62
+ });
63
+
64
+ it('returns null when native module is unavailable', () => {
65
+ const { TvosSearchView } = require('../index');
66
+ const result = TvosSearchView({
67
+ results: [],
68
+ onSearch: jest.fn(),
69
+ onSelectItem: jest.fn(),
70
+ });
71
+ expect(result).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe('SearchResult interface', () => {
76
+ it('accepts valid SearchResult objects', () => {
77
+ const validResult = {
78
+ id: 'test-123',
79
+ title: 'Test Movie',
80
+ subtitle: 'Optional subtitle',
81
+ imageUrl: 'https://example.com/poster.jpg',
82
+ };
83
+
84
+ expect(validResult.id).toBe('test-123');
85
+ expect(validResult.title).toBe('Test Movie');
86
+ expect(validResult.subtitle).toBe('Optional subtitle');
87
+ expect(validResult.imageUrl).toBe('https://example.com/poster.jpg');
88
+ });
89
+
90
+ it('accepts SearchResult with only required fields', () => {
91
+ const minimalResult = {
92
+ id: 'minimal-123',
93
+ title: 'Minimal Movie',
94
+ };
95
+
96
+ expect(minimalResult.id).toBe('minimal-123');
97
+ expect(minimalResult.title).toBe('Minimal Movie');
98
+ });
99
+ });
100
+
101
+ describe('TvosSearchViewProps defaults', () => {
102
+ it('all optional props have documented defaults', () => {
103
+ // This test documents the expected default values
104
+ // The actual defaults are applied in Swift (ExpoTvosSearchView.swift)
105
+ const expectedDefaults = {
106
+ columns: 5,
107
+ placeholder: 'Search...',
108
+ isLoading: false,
109
+ showTitle: false,
110
+ showSubtitle: false,
111
+ showFocusBorder: false,
112
+ topInset: 0,
113
+ showTitleOverlay: true,
114
+ enableMarquee: true,
115
+ marqueeDelay: 1.5,
116
+ };
117
+
118
+ // Verify default documentation matches Swift implementation
119
+ expect(expectedDefaults.columns).toBe(5);
120
+ expect(expectedDefaults.showTitleOverlay).toBe(true);
121
+ expect(expectedDefaults.enableMarquee).toBe(true);
122
+ expect(expectedDefaults.marqueeDelay).toBe(1.5);
123
+ });
124
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Jest test setup - helper functions for platform mocking
3
+ * Uses global state to persist mock values across module resets
4
+ */
5
+
6
+ // Import mocks to initialize globals
7
+ import './__mocks__/react-native';
8
+ import './__mocks__/expo-modules-core';
9
+
10
+ // Helper to simulate tvOS platform
11
+ export function mockTvOSPlatform(): void {
12
+ globalThis.__mockPlatformOS = 'ios';
13
+ globalThis.__mockPlatformIsTV = true;
14
+ }
15
+
16
+ // Helper to simulate iOS (non-TV) platform
17
+ export function mockIOSPlatform(): void {
18
+ globalThis.__mockPlatformOS = 'ios';
19
+ globalThis.__mockPlatformIsTV = false;
20
+ }
21
+
22
+ // Helper to simulate web platform
23
+ export function mockWebPlatform(): void {
24
+ globalThis.__mockPlatformOS = 'web';
25
+ globalThis.__mockPlatformIsTV = false;
26
+ }
27
+
28
+ // Helper to simulate Android platform
29
+ export function mockAndroidPlatform(): void {
30
+ globalThis.__mockPlatformOS = 'android';
31
+ globalThis.__mockPlatformIsTV = false;
32
+ }
33
+
34
+ // Helper to mock native module as available
35
+ export function mockNativeModuleAvailable(): void {
36
+ globalThis.__mockNativeViewAvailable = true;
37
+ }
38
+
39
+ // Helper to mock native module as unavailable
40
+ export function mockNativeModuleUnavailable(): void {
41
+ globalThis.__mockNativeViewAvailable = false;
42
+ }
43
+
44
+ // Reset mocks between tests
45
+ beforeEach(() => {
46
+ jest.resetModules();
47
+ mockWebPlatform();
48
+ mockNativeModuleUnavailable();
49
+ });
package/src/index.tsx ADDED
@@ -0,0 +1,261 @@
1
+ import React from "react";
2
+ import { ViewStyle, Platform } from "react-native";
3
+
4
+ /**
5
+ * Event payload for search text changes.
6
+ * Fired when the user types in the native search field.
7
+ */
8
+ export interface SearchEvent {
9
+ nativeEvent: {
10
+ /** The current search query string entered by the user */
11
+ query: string;
12
+ };
13
+ }
14
+
15
+ /**
16
+ * Event payload for item selection.
17
+ * Fired when the user selects a search result.
18
+ */
19
+ export interface SelectItemEvent {
20
+ nativeEvent: {
21
+ /** The unique identifier of the selected search result */
22
+ id: string;
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Represents a single search result displayed in the grid.
28
+ */
29
+ export interface SearchResult {
30
+ /** Unique identifier for the result (used in onSelectItem callback) */
31
+ id: string;
32
+ /** Primary display text for the result */
33
+ title: string;
34
+ /** Optional secondary text displayed below the title */
35
+ subtitle?: string;
36
+ /** Optional image URL for the result poster/thumbnail */
37
+ imageUrl?: string;
38
+ }
39
+
40
+ /**
41
+ * Props for the TvosSearchView component.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <TvosSearchView
46
+ * results={searchResults}
47
+ * columns={5}
48
+ * placeholder="Search movies..."
49
+ * isLoading={loading}
50
+ * topInset={140}
51
+ * onSearch={(e) => handleSearch(e.nativeEvent.query)}
52
+ * onSelectItem={(e) => navigateTo(e.nativeEvent.id)}
53
+ * style={{ flex: 1 }}
54
+ * />
55
+ * ```
56
+ */
57
+ export interface TvosSearchViewProps {
58
+ /**
59
+ * Array of search results to display in the grid.
60
+ * Each result should have a unique `id`.
61
+ * Arrays larger than 500 items are truncated.
62
+ * Results with empty `id` or `title` are skipped.
63
+ * @maximum 500
64
+ */
65
+ results: SearchResult[];
66
+
67
+ /**
68
+ * Number of columns in the results grid.
69
+ * Values outside 1-10 range are clamped.
70
+ * @default 5
71
+ * @minimum 1
72
+ * @maximum 10
73
+ */
74
+ columns?: number;
75
+
76
+ /**
77
+ * Placeholder text shown in the search field when empty.
78
+ * @default "Search..."
79
+ */
80
+ placeholder?: string;
81
+
82
+ /**
83
+ * Whether to show a loading indicator.
84
+ * @default false
85
+ */
86
+ isLoading?: boolean;
87
+
88
+ /**
89
+ * Show title text below each result card.
90
+ * @default false
91
+ */
92
+ showTitle?: boolean;
93
+
94
+ /**
95
+ * Show subtitle text below title.
96
+ * Requires `showTitle` to be true to be visible.
97
+ * @default false
98
+ */
99
+ showSubtitle?: boolean;
100
+
101
+ /**
102
+ * Show gold border on focused card.
103
+ * @default false
104
+ */
105
+ showFocusBorder?: boolean;
106
+
107
+ /**
108
+ * Extra top padding in points for tab bar clearance.
109
+ * Useful when the view is displayed under a navigation bar.
110
+ * Values outside 0-500 range are clamped.
111
+ * @default 0
112
+ * @minimum 0
113
+ * @maximum 500
114
+ */
115
+ topInset?: number;
116
+
117
+ /**
118
+ * Show title overlay with gradient at bottom of card.
119
+ * This displays the title on top of the image.
120
+ * @default true
121
+ */
122
+ showTitleOverlay?: boolean;
123
+
124
+ /**
125
+ * Enable marquee scrolling for long titles that overflow the card width.
126
+ * @default true
127
+ */
128
+ enableMarquee?: boolean;
129
+
130
+ /**
131
+ * Delay in seconds before marquee starts scrolling when item is focused.
132
+ * Values outside 0-60 range are clamped.
133
+ * @default 1.5
134
+ * @minimum 0
135
+ * @maximum 60
136
+ */
137
+ marqueeDelay?: number;
138
+
139
+ /**
140
+ * Text displayed when the search field is empty and no results are shown.
141
+ * @default "Search for movies and videos"
142
+ */
143
+ emptyStateText?: string;
144
+
145
+ /**
146
+ * Text displayed while searching (when loading with no results yet).
147
+ * @default "Searching..."
148
+ */
149
+ searchingText?: string;
150
+
151
+ /**
152
+ * Text displayed when search returns no results.
153
+ * @default "No results found"
154
+ */
155
+ noResultsText?: string;
156
+
157
+ /**
158
+ * Hint text displayed below the no results message.
159
+ * @default "Try a different search term"
160
+ */
161
+ noResultsHintText?: string;
162
+
163
+ /**
164
+ * Callback fired when the search text changes.
165
+ * Debounce this handler to avoid excessive API calls.
166
+ */
167
+ onSearch: (event: SearchEvent) => void;
168
+
169
+ /**
170
+ * Callback fired when a search result is selected.
171
+ * Use the `id` from the event to identify which result was selected.
172
+ */
173
+ onSelectItem: (event: SelectItemEvent) => void;
174
+
175
+ /**
176
+ * Optional style for the view container.
177
+ */
178
+ style?: ViewStyle;
179
+ }
180
+
181
+ // Safely try to load the native view - it may not be available if:
182
+ // 1. Running on a non-tvOS platform
183
+ // 2. Native module hasn't been built yet (needs expo prebuild)
184
+ // 3. expo-modules-core isn't properly installed
185
+ let NativeView: React.ComponentType<TvosSearchViewProps> | null = null;
186
+
187
+ if (Platform.OS === "ios" && Platform.isTV) {
188
+ try {
189
+ const { requireNativeViewManager } = require("expo-modules-core");
190
+ if (typeof requireNativeViewManager === "function") {
191
+ NativeView = requireNativeViewManager("ExpoTvosSearch");
192
+ }
193
+ } catch {
194
+ // Native module not available - will fall back to React Native implementation
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Native tvOS search view component using SwiftUI's `.searchable` modifier.
200
+ *
201
+ * This component provides a native search experience on tvOS with proper focus
202
+ * handling and keyboard navigation. On non-tvOS platforms or when the native
203
+ * module is unavailable, it renders `null` - use `isNativeSearchAvailable()`
204
+ * to check availability and render a fallback.
205
+ *
206
+ * @example
207
+ * ```tsx
208
+ * import { TvosSearchView, isNativeSearchAvailable } from 'expo-tvos-search';
209
+ *
210
+ * function SearchScreen() {
211
+ * const [results, setResults] = useState<SearchResult[]>([]);
212
+ *
213
+ * if (!isNativeSearchAvailable()) {
214
+ * return <FallbackSearchComponent />;
215
+ * }
216
+ *
217
+ * return (
218
+ * <TvosSearchView
219
+ * results={results}
220
+ * onSearch={(e) => fetchResults(e.nativeEvent.query)}
221
+ * onSelectItem={(e) => router.push(`/detail/${e.nativeEvent.id}`)}
222
+ * style={{ flex: 1 }}
223
+ * />
224
+ * );
225
+ * }
226
+ * ```
227
+ *
228
+ * @param props - Component props
229
+ * @returns The native search view on tvOS, or `null` if unavailable
230
+ */
231
+ export function TvosSearchView(props: TvosSearchViewProps) {
232
+ if (!NativeView) {
233
+ return null;
234
+ }
235
+ return <NativeView {...props} />;
236
+ }
237
+
238
+ /**
239
+ * Checks if the native tvOS search component is available.
240
+ *
241
+ * Returns `true` only when:
242
+ * - Running on tvOS (Platform.OS === "ios" && Platform.isTV)
243
+ * - The native module has been built (via `expo prebuild`)
244
+ * - expo-modules-core is properly installed
245
+ *
246
+ * Use this to conditionally render a fallback search implementation
247
+ * on non-tvOS platforms or when the native module is unavailable.
248
+ *
249
+ * @returns `true` if TvosSearchView will render, `false` if it will return null
250
+ *
251
+ * @example
252
+ * ```tsx
253
+ * if (!isNativeSearchAvailable()) {
254
+ * return <ReactNativeSearchFallback />;
255
+ * }
256
+ * return <TvosSearchView {...props} />;
257
+ * ```
258
+ */
259
+ export function isNativeSearchAvailable(): boolean {
260
+ return NativeView !== null;
261
+ }