expo-tvos-search 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +212 -312
- package/build/index.d.ts +57 -5
- package/build/index.d.ts.map +1 -1
- package/ios/CachedAsyncImage.swift +78 -0
- package/ios/ExpoTvosSearchModule.swift +31 -7
- package/ios/ExpoTvosSearchView.swift +250 -421
- package/ios/HexColorParser.swift +55 -0
- package/ios/MarqueeText.swift +28 -51
- package/ios/SearchResultCard.swift +172 -0
- package/ios/SearchResultItem.swift +8 -0
- package/ios/Tests/HexColorParserTests.swift +183 -0
- package/ios/Tests/SearchViewModelTests.swift +1 -1
- package/ios/TvosSearchContentView.swift +125 -0
- package/package.json +2 -1
- package/src/__tests__/events.test.ts +128 -0
- package/src/__tests__/index.test.tsx +47 -1
- package/src/index.tsx +61 -5
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Parses hex color strings into RGBA components.
|
|
4
|
+
/// Extracted from the Color extension to enable unit testing without tvOS target.
|
|
5
|
+
struct HexColorParser {
|
|
6
|
+
struct RGBA: Equatable {
|
|
7
|
+
let red: Double
|
|
8
|
+
let green: Double
|
|
9
|
+
let blue: Double
|
|
10
|
+
let alpha: Double
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Maximum input length for hex color strings to prevent DoS from very long strings.
|
|
14
|
+
static let maxInputLength = 20
|
|
15
|
+
|
|
16
|
+
/// Parses a hex color string into RGBA components (0.0–1.0 range).
|
|
17
|
+
/// Supports 3-char (RGB), 6-char (RRGGBB), and 8-char (AARRGGBB) formats.
|
|
18
|
+
/// Leading "#" or other non-alphanumeric characters are stripped.
|
|
19
|
+
/// Returns nil if the string cannot be parsed.
|
|
20
|
+
static func parse(_ hex: String) -> RGBA? {
|
|
21
|
+
guard hex.count <= maxInputLength else {
|
|
22
|
+
return nil
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
26
|
+
|
|
27
|
+
guard hex.count == 3 || hex.count == 6 || hex.count == 8 else {
|
|
28
|
+
return nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var int: UInt64 = 0
|
|
32
|
+
guard Scanner(string: hex).scanHexInt64(&int) else {
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let a, r, g, b: UInt64
|
|
37
|
+
switch hex.count {
|
|
38
|
+
case 3: // RGB (12-bit)
|
|
39
|
+
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
|
40
|
+
case 6: // RGB (24-bit)
|
|
41
|
+
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
|
42
|
+
case 8: // ARGB (32-bit)
|
|
43
|
+
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
44
|
+
default:
|
|
45
|
+
return nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return RGBA(
|
|
49
|
+
red: Double(r) / 255,
|
|
50
|
+
green: Double(g) / 255,
|
|
51
|
+
blue: Double(b) / 255,
|
|
52
|
+
alpha: Double(a) / 255
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
package/ios/MarqueeText.swift
CHANGED
|
@@ -15,7 +15,6 @@ struct MarqueeText: View {
|
|
|
15
15
|
@State private var textWidth: CGFloat = 0
|
|
16
16
|
@State private var containerWidth: CGFloat = 0
|
|
17
17
|
@State private var offset: CGFloat = 0
|
|
18
|
-
@State private var animationTask: Task<Void, Never>?
|
|
19
18
|
|
|
20
19
|
private let calculator = MarqueeAnimationCalculator()
|
|
21
20
|
|
|
@@ -39,6 +38,10 @@ struct MarqueeText: View {
|
|
|
39
38
|
calculator.shouldScroll(textWidth: textWidth, containerWidth: containerWidth)
|
|
40
39
|
}
|
|
41
40
|
|
|
41
|
+
private var shouldAnimate: Bool {
|
|
42
|
+
animate && needsScroll
|
|
43
|
+
}
|
|
44
|
+
|
|
42
45
|
var body: some View {
|
|
43
46
|
GeometryReader { geometry in
|
|
44
47
|
ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
|
|
@@ -57,11 +60,11 @@ struct MarqueeText: View {
|
|
|
57
60
|
// Visible text content
|
|
58
61
|
Group {
|
|
59
62
|
if needsScroll {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.font(font)
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
HStack(spacing: calculator.spacing) {
|
|
64
|
+
Text(text).font(font).fixedSize()
|
|
65
|
+
Text(text).font(font).fixedSize()
|
|
66
|
+
}
|
|
67
|
+
.offset(x: offset)
|
|
65
68
|
} else {
|
|
66
69
|
Text(text)
|
|
67
70
|
.font(font)
|
|
@@ -81,24 +84,29 @@ struct MarqueeText: View {
|
|
|
81
84
|
.onAppear {
|
|
82
85
|
containerWidth = geometry.size.width
|
|
83
86
|
}
|
|
84
|
-
.
|
|
85
|
-
if shouldAnimate
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
.task(id: shouldAnimate) {
|
|
88
|
+
if shouldAnimate {
|
|
89
|
+
do {
|
|
90
|
+
try await Task.sleep(nanoseconds: UInt64(startDelay * 1_000_000_000))
|
|
91
|
+
} catch {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
guard !Task.isCancelled else { return }
|
|
95
|
+
let distance = calculator.scrollDistance(textWidth: textWidth)
|
|
96
|
+
let duration = calculator.animationDuration(for: distance)
|
|
97
|
+
withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
|
|
98
|
+
offset = -distance
|
|
99
|
+
}
|
|
94
100
|
} else {
|
|
95
|
-
|
|
101
|
+
if offset != 0 {
|
|
102
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
103
|
+
offset = 0
|
|
104
|
+
}
|
|
105
|
+
}
|
|
96
106
|
}
|
|
97
107
|
}
|
|
98
108
|
.onDisappear {
|
|
99
|
-
|
|
100
|
-
animationTask?.cancel()
|
|
101
|
-
animationTask = nil
|
|
109
|
+
offset = 0
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
}
|
|
@@ -123,37 +131,6 @@ struct MarqueeText: View {
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
private func startScrolling() {
|
|
127
|
-
animationTask?.cancel()
|
|
128
|
-
offset = 0
|
|
129
|
-
|
|
130
|
-
let distance = calculator.scrollDistance(textWidth: textWidth)
|
|
131
|
-
let duration = calculator.animationDuration(for: distance)
|
|
132
|
-
|
|
133
|
-
animationTask = Task {
|
|
134
|
-
do {
|
|
135
|
-
try await Task.sleep(nanoseconds: UInt64(startDelay * 1_000_000_000))
|
|
136
|
-
} catch {
|
|
137
|
-
return // Task was cancelled
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
guard !Task.isCancelled else { return }
|
|
141
|
-
|
|
142
|
-
await MainActor.run {
|
|
143
|
-
withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) {
|
|
144
|
-
offset = -distance
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private func stopScrolling() {
|
|
151
|
-
animationTask?.cancel()
|
|
152
|
-
animationTask = nil
|
|
153
|
-
withAnimation(.easeOut(duration: 0.2)) {
|
|
154
|
-
offset = 0
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
134
|
}
|
|
158
135
|
|
|
159
136
|
/// PreferenceKey for measuring text width reactively
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#if os(tvOS)
|
|
2
|
+
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
/// Custom shape for cards with selectively rounded corners
|
|
6
|
+
/// Provides backwards compatibility for tvOS versions before 16.0
|
|
7
|
+
struct SelectiveRoundedRectangle: Shape {
|
|
8
|
+
var topLeadingRadius: CGFloat
|
|
9
|
+
var topTrailingRadius: CGFloat
|
|
10
|
+
var bottomLeadingRadius: CGFloat
|
|
11
|
+
var bottomTrailingRadius: CGFloat
|
|
12
|
+
|
|
13
|
+
func path(in rect: CGRect) -> Path {
|
|
14
|
+
var path = Path()
|
|
15
|
+
|
|
16
|
+
let tl = min(topLeadingRadius, min(rect.width, rect.height) / 2)
|
|
17
|
+
let tr = min(topTrailingRadius, min(rect.width, rect.height) / 2)
|
|
18
|
+
let bl = min(bottomLeadingRadius, min(rect.width, rect.height) / 2)
|
|
19
|
+
let br = min(bottomTrailingRadius, min(rect.width, rect.height) / 2)
|
|
20
|
+
|
|
21
|
+
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
|
|
22
|
+
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
|
|
23
|
+
path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr),
|
|
24
|
+
radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
|
|
25
|
+
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
|
|
26
|
+
path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br),
|
|
27
|
+
radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
|
28
|
+
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
|
|
29
|
+
path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl),
|
|
30
|
+
radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
|
31
|
+
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
|
|
32
|
+
path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl),
|
|
33
|
+
radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
|
34
|
+
path.closeSubpath()
|
|
35
|
+
|
|
36
|
+
return path
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
struct SearchResultCard: View {
|
|
41
|
+
let item: SearchResultItem
|
|
42
|
+
let showTitle: Bool
|
|
43
|
+
let showSubtitle: Bool
|
|
44
|
+
let showFocusBorder: Bool
|
|
45
|
+
let showTitleOverlay: Bool
|
|
46
|
+
let enableMarquee: Bool
|
|
47
|
+
let marqueeDelay: Double
|
|
48
|
+
let textColor: Color?
|
|
49
|
+
let accentColor: Color
|
|
50
|
+
let cardWidth: CGFloat
|
|
51
|
+
let cardHeight: CGFloat
|
|
52
|
+
let imageContentMode: ContentMode
|
|
53
|
+
let cardPadding: CGFloat
|
|
54
|
+
let overlayTitleSize: CGFloat
|
|
55
|
+
let onSelect: () -> Void
|
|
56
|
+
@FocusState private var isFocused: Bool
|
|
57
|
+
|
|
58
|
+
private let placeholderColor = Color(white: 0.2)
|
|
59
|
+
|
|
60
|
+
/// Computed shape for the card with selective rounded corners.
|
|
61
|
+
/// Bottom corners are rounded only when no title/subtitle section is displayed.
|
|
62
|
+
private var cardShape: SelectiveRoundedRectangle {
|
|
63
|
+
SelectiveRoundedRectangle(
|
|
64
|
+
topLeadingRadius: 12,
|
|
65
|
+
topTrailingRadius: 12,
|
|
66
|
+
bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
|
|
67
|
+
bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Title overlay height
|
|
72
|
+
private var overlayHeight: CGFloat { cardHeight * 0.25 } // 25% of card
|
|
73
|
+
|
|
74
|
+
var body: some View {
|
|
75
|
+
Button(action: onSelect) {
|
|
76
|
+
VStack(alignment: .leading, spacing: showTitle || showSubtitle ? 12 : 0) {
|
|
77
|
+
ZStack(alignment: .bottom) {
|
|
78
|
+
// Card image content
|
|
79
|
+
ZStack {
|
|
80
|
+
placeholderColor
|
|
81
|
+
|
|
82
|
+
if let imageUrl = item.imageUrl, let url = URL(string: imageUrl) {
|
|
83
|
+
CachedAsyncImage(
|
|
84
|
+
url: url,
|
|
85
|
+
contentMode: imageContentMode,
|
|
86
|
+
width: cardWidth,
|
|
87
|
+
height: cardHeight
|
|
88
|
+
)
|
|
89
|
+
} else {
|
|
90
|
+
placeholderIcon
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
.frame(width: cardWidth, height: cardHeight)
|
|
94
|
+
.clipped()
|
|
95
|
+
|
|
96
|
+
// Title overlay with native material blur
|
|
97
|
+
if showTitleOverlay {
|
|
98
|
+
ZStack {
|
|
99
|
+
Rectangle()
|
|
100
|
+
.fill(.ultraThinMaterial)
|
|
101
|
+
.frame(width: cardWidth, height: overlayHeight)
|
|
102
|
+
|
|
103
|
+
if enableMarquee {
|
|
104
|
+
MarqueeText(
|
|
105
|
+
item.title,
|
|
106
|
+
font: .system(size: overlayTitleSize, weight: .semibold),
|
|
107
|
+
leftFade: 12,
|
|
108
|
+
rightFade: 12,
|
|
109
|
+
startDelay: marqueeDelay,
|
|
110
|
+
animate: isFocused
|
|
111
|
+
)
|
|
112
|
+
.foregroundColor(.white)
|
|
113
|
+
.padding(.horizontal, cardPadding)
|
|
114
|
+
} else {
|
|
115
|
+
Text(item.title)
|
|
116
|
+
.font(.system(size: overlayTitleSize, weight: .semibold))
|
|
117
|
+
.foregroundColor(.white)
|
|
118
|
+
.lineLimit(2)
|
|
119
|
+
.multilineTextAlignment(.center)
|
|
120
|
+
.padding(.horizontal, cardPadding)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
.frame(width: cardWidth, height: overlayHeight)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
.frame(width: cardWidth, height: cardHeight)
|
|
127
|
+
.clipShape(cardShape)
|
|
128
|
+
.overlay(
|
|
129
|
+
cardShape.stroke(showFocusBorder && isFocused ? accentColor : Color.clear, lineWidth: 4)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if showTitle || showSubtitle {
|
|
133
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
134
|
+
if showTitle {
|
|
135
|
+
Text(item.title)
|
|
136
|
+
.font(.callout)
|
|
137
|
+
.fontWeight(.medium)
|
|
138
|
+
.lineLimit(2)
|
|
139
|
+
.multilineTextAlignment(.leading)
|
|
140
|
+
.foregroundColor(.primary)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if showSubtitle, let subtitle = item.subtitle {
|
|
144
|
+
Text(subtitle)
|
|
145
|
+
.font(.caption)
|
|
146
|
+
.foregroundColor(textColor ?? .secondary)
|
|
147
|
+
.lineLimit(1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
.padding(cardPadding)
|
|
151
|
+
.frame(width: cardWidth, alignment: .leading)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
.buttonStyle(.card)
|
|
156
|
+
.focused($isFocused)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private var placeholderIcon: some View {
|
|
160
|
+
ZStack {
|
|
161
|
+
Circle()
|
|
162
|
+
.fill(Color.white.opacity(0.1))
|
|
163
|
+
.frame(width: 120, height: 120)
|
|
164
|
+
|
|
165
|
+
Image(systemName: "photo")
|
|
166
|
+
.font(.system(size: 60, weight: .light))
|
|
167
|
+
.foregroundColor(.white.opacity(0.7))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#endif
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
/// Unit tests for HexColorParser
|
|
4
|
+
final class HexColorParserTests: XCTestCase {
|
|
5
|
+
|
|
6
|
+
// MARK: - 6-character hex (RRGGBB)
|
|
7
|
+
|
|
8
|
+
func testParse_6charWithHash_white() {
|
|
9
|
+
let rgba = HexColorParser.parse("#FFFFFF")
|
|
10
|
+
XCTAssertNotNil(rgba)
|
|
11
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
12
|
+
XCTAssertEqual(rgba!.green, 1.0, accuracy: 0.001)
|
|
13
|
+
XCTAssertEqual(rgba!.blue, 1.0, accuracy: 0.001)
|
|
14
|
+
XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func testParse_6charWithHash_black() {
|
|
18
|
+
let rgba = HexColorParser.parse("#000000")
|
|
19
|
+
XCTAssertNotNil(rgba)
|
|
20
|
+
XCTAssertEqual(rgba!.red, 0.0, accuracy: 0.001)
|
|
21
|
+
XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
|
|
22
|
+
XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
|
|
23
|
+
XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func testParse_6charWithHash_red() {
|
|
27
|
+
let rgba = HexColorParser.parse("#FF0000")
|
|
28
|
+
XCTAssertNotNil(rgba)
|
|
29
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
30
|
+
XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
|
|
31
|
+
XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testParse_6charWithoutHash() {
|
|
35
|
+
let rgba = HexColorParser.parse("FFC312")
|
|
36
|
+
XCTAssertNotNil(rgba)
|
|
37
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
38
|
+
XCTAssertEqual(rgba!.green, 0.765, accuracy: 0.001)
|
|
39
|
+
XCTAssertEqual(rgba!.blue, Double(0x12) / 255, accuracy: 0.001)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func testParse_6charLowercase() {
|
|
43
|
+
let rgba = HexColorParser.parse("#ff5733")
|
|
44
|
+
XCTAssertNotNil(rgba)
|
|
45
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
46
|
+
XCTAssertEqual(rgba!.green, Double(0x57) / 255, accuracy: 0.001)
|
|
47
|
+
XCTAssertEqual(rgba!.blue, Double(0x33) / 255, accuracy: 0.001)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - 3-character hex (RGB shorthand)
|
|
51
|
+
|
|
52
|
+
func testParse_3charWithHash_white() {
|
|
53
|
+
let rgba = HexColorParser.parse("#FFF")
|
|
54
|
+
XCTAssertNotNil(rgba)
|
|
55
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
56
|
+
XCTAssertEqual(rgba!.green, 1.0, accuracy: 0.001)
|
|
57
|
+
XCTAssertEqual(rgba!.blue, 1.0, accuracy: 0.001)
|
|
58
|
+
XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func testParse_3charWithHash_black() {
|
|
62
|
+
let rgba = HexColorParser.parse("#000")
|
|
63
|
+
XCTAssertNotNil(rgba)
|
|
64
|
+
XCTAssertEqual(rgba!.red, 0.0, accuracy: 0.001)
|
|
65
|
+
XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
|
|
66
|
+
XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testParse_3char_expandsCorrectly() {
|
|
70
|
+
// #F80 should expand to #FF8800
|
|
71
|
+
let rgba = HexColorParser.parse("#F80")
|
|
72
|
+
XCTAssertNotNil(rgba)
|
|
73
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
74
|
+
XCTAssertEqual(rgba!.green, Double(0x88) / 255, accuracy: 0.001)
|
|
75
|
+
XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// MARK: - 8-character hex (AARRGGBB)
|
|
79
|
+
|
|
80
|
+
func testParse_8charWithHash_fullAlpha() {
|
|
81
|
+
let rgba = HexColorParser.parse("#FFFF0000")
|
|
82
|
+
XCTAssertNotNil(rgba)
|
|
83
|
+
XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
|
|
84
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
85
|
+
XCTAssertEqual(rgba!.green, 0.0, accuracy: 0.001)
|
|
86
|
+
XCTAssertEqual(rgba!.blue, 0.0, accuracy: 0.001)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func testParse_8charWithHash_halfAlpha() {
|
|
90
|
+
let rgba = HexColorParser.parse("#80FF0000")
|
|
91
|
+
XCTAssertNotNil(rgba)
|
|
92
|
+
XCTAssertEqual(rgba!.alpha, Double(0x80) / 255, accuracy: 0.001)
|
|
93
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func testParse_8charWithHash_zeroAlpha() {
|
|
97
|
+
let rgba = HexColorParser.parse("#00FFFFFF")
|
|
98
|
+
XCTAssertNotNil(rgba)
|
|
99
|
+
XCTAssertEqual(rgba!.alpha, 0.0, accuracy: 0.001)
|
|
100
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
101
|
+
XCTAssertEqual(rgba!.green, 1.0, accuracy: 0.001)
|
|
102
|
+
XCTAssertEqual(rgba!.blue, 1.0, accuracy: 0.001)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - Invalid inputs
|
|
106
|
+
|
|
107
|
+
func testParse_emptyString_returnsNil() {
|
|
108
|
+
XCTAssertNil(HexColorParser.parse(""))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func testParse_hashOnly_returnsNil() {
|
|
112
|
+
XCTAssertNil(HexColorParser.parse("#"))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func testParse_invalidHexChars_returnsNil() {
|
|
116
|
+
XCTAssertNil(HexColorParser.parse("#GGGGGG"))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func testParse_wrongLength_4chars_returnsNil() {
|
|
120
|
+
XCTAssertNil(HexColorParser.parse("#ABCD"))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func testParse_wrongLength_5chars_returnsNil() {
|
|
124
|
+
XCTAssertNil(HexColorParser.parse("#ABCDE"))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func testParse_wrongLength_7chars_returnsNil() {
|
|
128
|
+
XCTAssertNil(HexColorParser.parse("#ABCDEFF"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func testParse_tooLongString_returnsNil() {
|
|
132
|
+
let longString = String(repeating: "A", count: 21)
|
|
133
|
+
XCTAssertNil(HexColorParser.parse(longString))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func testParse_maxLengthString_stillParsed() {
|
|
137
|
+
// 20 chars is at the limit. After stripping "#", we get 19 chars which is > 8, so nil.
|
|
138
|
+
let atLimit = "#" + String(repeating: "F", count: 19)
|
|
139
|
+
XCTAssertEqual(atLimit.count, 20)
|
|
140
|
+
XCTAssertNil(HexColorParser.parse(atLimit)) // 19 hex chars is not 3, 6, or 8
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func testParse_exactlyMaxLength_valid8char() {
|
|
144
|
+
// A valid 8-char hex within the 20-char limit
|
|
145
|
+
let valid = "#FFAABBCC"
|
|
146
|
+
XCTAssertEqual(valid.count, 9) // well under 20
|
|
147
|
+
XCTAssertNotNil(HexColorParser.parse(valid))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MARK: - Edge cases
|
|
151
|
+
|
|
152
|
+
func testParse_mixedCase() {
|
|
153
|
+
let rgba = HexColorParser.parse("#aAbBcC")
|
|
154
|
+
XCTAssertNotNil(rgba)
|
|
155
|
+
XCTAssertEqual(rgba!.red, Double(0xAA) / 255, accuracy: 0.001)
|
|
156
|
+
XCTAssertEqual(rgba!.green, Double(0xBB) / 255, accuracy: 0.001)
|
|
157
|
+
XCTAssertEqual(rgba!.blue, Double(0xCC) / 255, accuracy: 0.001)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func testParse_defaultAccentColor_FFC312() {
|
|
161
|
+
// This is the actual default accent color used by the library
|
|
162
|
+
let rgba = HexColorParser.parse("#FFC312")
|
|
163
|
+
XCTAssertNotNil(rgba)
|
|
164
|
+
XCTAssertEqual(rgba!.red, 1.0, accuracy: 0.001)
|
|
165
|
+
XCTAssertEqual(rgba!.green, 0.765, accuracy: 0.001)
|
|
166
|
+
XCTAssertEqual(rgba!.blue, Double(0x12) / 255, accuracy: 0.001)
|
|
167
|
+
XCTAssertEqual(rgba!.alpha, 1.0, accuracy: 0.001)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// MARK: - RGBA Equatable conformance
|
|
171
|
+
|
|
172
|
+
func testRGBA_equality() {
|
|
173
|
+
let a = HexColorParser.RGBA(red: 1.0, green: 0.5, blue: 0.0, alpha: 1.0)
|
|
174
|
+
let b = HexColorParser.RGBA(red: 1.0, green: 0.5, blue: 0.0, alpha: 1.0)
|
|
175
|
+
XCTAssertEqual(a, b)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func testRGBA_inequality() {
|
|
179
|
+
let a = HexColorParser.RGBA(red: 1.0, green: 0.5, blue: 0.0, alpha: 1.0)
|
|
180
|
+
let b = HexColorParser.RGBA(red: 0.0, green: 0.5, blue: 0.0, alpha: 1.0)
|
|
181
|
+
XCTAssertNotEqual(a, b)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -35,7 +35,7 @@ final class SearchViewModelTests: XCTestCase {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
func testInitialState_defaultPlaceholder() {
|
|
38
|
-
XCTAssertEqual(viewModel.placeholder, "Search
|
|
38
|
+
XCTAssertEqual(viewModel.placeholder, "Search...")
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
func testInitialState_showTitleFalse() {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#if os(tvOS)
|
|
2
|
+
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
struct TvosSearchContentView: View {
|
|
6
|
+
@ObservedObject var viewModel: SearchViewModel
|
|
7
|
+
|
|
8
|
+
private var gridColumns: [GridItem] {
|
|
9
|
+
Array(repeating: GridItem(.flexible(), spacing: viewModel.cardMargin), count: viewModel.columns)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
var body: some View {
|
|
13
|
+
NavigationView {
|
|
14
|
+
ZStack {
|
|
15
|
+
Group {
|
|
16
|
+
if viewModel.results.isEmpty && viewModel.searchText.isEmpty {
|
|
17
|
+
emptyStateView
|
|
18
|
+
} else if viewModel.results.isEmpty && !viewModel.searchText.isEmpty {
|
|
19
|
+
if viewModel.isLoading {
|
|
20
|
+
searchingStateView
|
|
21
|
+
} else {
|
|
22
|
+
noResultsView
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
resultsGridView
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Loading overlay when loading with results
|
|
30
|
+
if viewModel.isLoading && !viewModel.results.isEmpty {
|
|
31
|
+
loadingOverlay
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
.searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
|
|
35
|
+
.onChange(of: viewModel.searchText) { newValue in
|
|
36
|
+
viewModel.onSearch?(newValue)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
.padding(.top, viewModel.topInset)
|
|
40
|
+
.ignoresSafeArea(.all, edges: .top)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private var emptyStateView: some View {
|
|
44
|
+
VStack(spacing: 20) {
|
|
45
|
+
Image(systemName: "magnifyingglass")
|
|
46
|
+
.font(.system(size: 80))
|
|
47
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
48
|
+
Text(viewModel.emptyStateText)
|
|
49
|
+
.font(.headline)
|
|
50
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
51
|
+
}
|
|
52
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private var searchingStateView: some View {
|
|
56
|
+
VStack(spacing: 20) {
|
|
57
|
+
ProgressView()
|
|
58
|
+
.scaleEffect(1.5)
|
|
59
|
+
Text(viewModel.searchingText)
|
|
60
|
+
.font(.headline)
|
|
61
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
62
|
+
}
|
|
63
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private var noResultsView: some View {
|
|
67
|
+
VStack(spacing: 20) {
|
|
68
|
+
Image(systemName: "film.stack")
|
|
69
|
+
.font(.system(size: 80))
|
|
70
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
71
|
+
Text(viewModel.noResultsText)
|
|
72
|
+
.font(.headline)
|
|
73
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
74
|
+
Text(viewModel.noResultsHintText)
|
|
75
|
+
.font(.subheadline)
|
|
76
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
77
|
+
}
|
|
78
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private var loadingOverlay: some View {
|
|
82
|
+
VStack {
|
|
83
|
+
HStack {
|
|
84
|
+
Spacer()
|
|
85
|
+
ProgressView()
|
|
86
|
+
.padding(16)
|
|
87
|
+
.background(Color.black.opacity(0.6))
|
|
88
|
+
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
89
|
+
}
|
|
90
|
+
.padding(.trailing, 60)
|
|
91
|
+
.padding(.top, 20)
|
|
92
|
+
Spacer()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private var resultsGridView: some View {
|
|
97
|
+
ScrollView {
|
|
98
|
+
LazyVGrid(columns: gridColumns, spacing: viewModel.cardMargin) {
|
|
99
|
+
ForEach(viewModel.results) { item in
|
|
100
|
+
SearchResultCard(
|
|
101
|
+
item: item,
|
|
102
|
+
showTitle: viewModel.showTitle,
|
|
103
|
+
showSubtitle: viewModel.showSubtitle,
|
|
104
|
+
showFocusBorder: viewModel.showFocusBorder,
|
|
105
|
+
showTitleOverlay: viewModel.showTitleOverlay,
|
|
106
|
+
enableMarquee: viewModel.enableMarquee,
|
|
107
|
+
marqueeDelay: viewModel.marqueeDelay,
|
|
108
|
+
textColor: viewModel.textColor,
|
|
109
|
+
accentColor: viewModel.accentColor,
|
|
110
|
+
cardWidth: viewModel.cardWidth,
|
|
111
|
+
cardHeight: viewModel.cardHeight,
|
|
112
|
+
imageContentMode: viewModel.imageContentMode,
|
|
113
|
+
cardPadding: viewModel.cardPadding,
|
|
114
|
+
overlayTitleSize: viewModel.overlayTitleSize,
|
|
115
|
+
onSelect: { viewModel.onSelectItem?(item.id) }
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
.padding(.horizontal, 60)
|
|
120
|
+
.padding(.vertical, 40)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#endif
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-tvos-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Native tvOS search view using SwiftUI .searchable modifier for Expo/React Native",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"ios",
|
|
74
74
|
"expo-module.config.json",
|
|
75
75
|
"README.md",
|
|
76
|
+
"CHANGELOG.md",
|
|
76
77
|
"LICENSE"
|
|
77
78
|
],
|
|
78
79
|
"engines": {
|