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/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
|
+
}
|