Просмотр исходного кода

Add hover feedback across dashboard controls

Introduce HoverableButton/HoverableView usage for Find Jobs CTA, job
actions, saved toggle styling, dismiss control, and sidebar rows. Add
theme hover colors, pointer cursors, and subtle background transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 недель назад: 3
Родитель
Сommit
adcd8474ea
1 измененных файлов с 184 добавлено и 12 удалено
  1. 184 12
      App for Indeed/Views/DashboardView.swift

+ 184 - 12
App for Indeed/Views/DashboardView.swift

@@ -37,6 +37,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
37
         static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
37
         static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
38
         /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
38
         /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
39
         static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
39
         static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
40
+        /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar.
41
+        static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
42
+        static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1)
43
+        static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2)
44
+        static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1)
45
+        static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04)
40
     }
46
     }
41
 
47
 
42
     /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
48
     /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
@@ -64,9 +70,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
64
     private let searchCard = NSView()
70
     private let searchCard = NSView()
65
     private let jobSearchIcon = NSImageView()
71
     private let jobSearchIcon = NSImageView()
66
     private let jobKeywordsField = NSTextField()
72
     private let jobKeywordsField = NSTextField()
67
-    private let findJobsButton = NSButton()
73
+    private let findJobsButton = HoverableButton()
68
     private let findJobsCTAHost = NSView()
74
     private let findJobsCTAHost = NSView()
69
-    private let findJobsCTAChrome = NSView()
75
+    private let findJobsCTAChrome = HoverableView()
70
     private var findJobsCTAGradientLayer: CAGradientLayer?
76
     private var findJobsCTAGradientLayer: CAGradientLayer?
71
     private let jobListingsScrollView = NSScrollView()
77
     private let jobListingsScrollView = NSScrollView()
72
     /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
78
     /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
@@ -411,6 +417,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
411
         applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
417
         applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
412
         applyButton.contentTintColor = Theme.proCTAText
418
         applyButton.contentTintColor = Theme.proCTAText
413
         applyButton.focusRingType = .none
419
         applyButton.focusRingType = .none
420
+        applyButton.pointerCursor = true
421
+        applyButton.hoverHandler = { [weak applyButton] hovering in
422
+            applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
423
+        }
414
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
424
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
415
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
425
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
416
 
426
 
@@ -424,6 +434,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
424
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
434
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
425
         savedButton.focusRingType = .none
435
         savedButton.focusRingType = .none
426
         savedButton.state = savedOn ? .on : .off
436
         savedButton.state = savedOn ? .on : .off
437
+        savedButton.pointerCursor = true
438
+        savedButton.hoverHandler = { [weak self, weak savedButton] _ in
439
+            guard let savedButton = savedButton else { return }
440
+            self?.styleJobSavedButton(savedButton)
441
+        }
427
         styleJobSavedButton(savedButton)
442
         styleJobSavedButton(savedButton)
428
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
443
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
429
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
444
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
@@ -442,6 +457,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
442
         dismissButton.action = #selector(didTapJobDismiss(_:))
457
         dismissButton.action = #selector(didTapJobDismiss(_:))
443
         dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
458
         dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
444
         dismissButton.focusRingType = .none
459
         dismissButton.focusRingType = .none
460
+        dismissButton.wantsLayer = true
461
+        dismissButton.layer?.cornerRadius = 14
462
+        dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
463
+        dismissButton.pointerCursor = true
464
+        dismissButton.hoverHandler = { [weak dismissButton] hovering in
465
+            dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor
466
+            dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText
467
+        }
445
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
468
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
446
 
469
 
447
         let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
470
         let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
@@ -500,13 +523,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
500
         button.wantsLayer = true
523
         button.wantsLayer = true
501
         button.layer?.cornerRadius = 6
524
         button.layer?.cornerRadius = 6
502
         let on = button.state == .on
525
         let on = button.state == .on
526
+        let hovering = (button as? HoverableButton)?.isHovering ?? false
503
         if on {
527
         if on {
504
-            button.layer?.backgroundColor = Theme.selectionFill.cgColor
528
+            button.layer?.backgroundColor = (hovering ? Theme.selectionFillHover : Theme.selectionFill).cgColor
505
             button.layer?.borderWidth = 1
529
             button.layer?.borderWidth = 1
506
             button.layer?.borderColor = Theme.brandBlue.cgColor
530
             button.layer?.borderColor = Theme.brandBlue.cgColor
507
             button.contentTintColor = Theme.brandBlue
531
             button.contentTintColor = Theme.brandBlue
508
         } else {
532
         } else {
509
-            button.layer?.backgroundColor = Theme.cardBackground.cgColor
533
+            button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
510
             button.layer?.borderWidth = 1
534
             button.layer?.borderWidth = 1
511
             button.layer?.borderColor = Theme.border.cgColor
535
             button.layer?.borderColor = Theme.border.cgColor
512
             button.contentTintColor = Theme.primaryText
536
             button.contentTintColor = Theme.primaryText
@@ -626,6 +650,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
626
         findJobsCTAChrome.layer?.addSublayer(gradient)
650
         findJobsCTAChrome.layer?.addSublayer(gradient)
627
         findJobsCTAGradientLayer = gradient
651
         findJobsCTAGradientLayer = gradient
628
 
652
 
653
+        // Tracks hover over the full pill (the button only covers an inset area), so the gradient darkens whenever the mouse is anywhere over the CTA.
654
+        findJobsCTAChrome.pointerCursor = true
655
+        findJobsCTAChrome.hoverHandler = { [weak self] hovering in
656
+            guard let layer = self?.findJobsCTAGradientLayer else { return }
657
+            CATransaction.begin()
658
+            CATransaction.setAnimationDuration(0.15)
659
+            layer.colors = hovering
660
+                ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor]
661
+                : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
662
+            CATransaction.commit()
663
+        }
664
+
629
         findJobsButton.translatesAutoresizingMaskIntoConstraints = false
665
         findJobsButton.translatesAutoresizingMaskIntoConstraints = false
630
         findJobsButton.title = ""
666
         findJobsButton.title = ""
631
         findJobsButton.attributedTitle = NSAttributedString(
667
         findJobsButton.attributedTitle = NSAttributedString(
@@ -977,9 +1013,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
977
             rowHost.translatesAutoresizingMaskIntoConstraints = false
1013
             rowHost.translatesAutoresizingMaskIntoConstraints = false
978
             rowHost.wantsLayer = true
1014
             rowHost.wantsLayer = true
979
             rowHost.layer?.cornerRadius = 8
1015
             rowHost.layer?.cornerRadius = 8
980
-            if isSelected {
981
-                rowHost.layer?.backgroundColor = Theme.selectionFill.cgColor
982
-            }
1016
+            rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil
1017
+            rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill
983
             rowHost.setAccessibilityLabel(item.title)
1018
             rowHost.setAccessibilityLabel(item.title)
984
             rowHost.setAccessibilityRole(.button)
1019
             rowHost.setAccessibilityRole(.button)
985
             rowHost.setAccessibilitySelected(isSelected)
1020
             rowHost.setAccessibilitySelected(isSelected)
@@ -1089,7 +1124,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1089
         let innerContentWidth = cardWidth - 28
1124
         let innerContentWidth = cardWidth - 28
1090
         upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
1125
         upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
1091
 
1126
 
1092
-        let upgradeButton = NSButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
1127
+        let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
1093
         upgradeButton.isBordered = false
1128
         upgradeButton.isBordered = false
1094
         upgradeButton.bezelStyle = .rounded
1129
         upgradeButton.bezelStyle = .rounded
1095
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
1130
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
@@ -1100,6 +1135,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1100
         upgradeButton.layer?.cornerRadius = 20
1135
         upgradeButton.layer?.cornerRadius = 20
1101
         upgradeButton.translatesAutoresizingMaskIntoConstraints = false
1136
         upgradeButton.translatesAutoresizingMaskIntoConstraints = false
1102
         upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
1137
         upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
1138
+        upgradeButton.pointerCursor = true
1139
+        upgradeButton.hoverHandler = { [weak upgradeButton] hovering in
1140
+            upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor
1141
+        }
1103
 
1142
 
1104
         inner.addArrangedSubview(eyebrowRow)
1143
         inner.addArrangedSubview(eyebrowRow)
1105
         inner.addArrangedSubview(headline)
1144
         inner.addArrangedSubview(headline)
@@ -1153,19 +1192,128 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1153
 }
1192
 }
1154
 
1193
 
1155
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1194
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
1156
-private final class JobPayloadButton: NSButton {
1195
+private final class JobPayloadButton: HoverableButton {
1157
     var jobPayload: JobListing?
1196
     var jobPayload: JobListing?
1158
     var cardContext: JobListingCardContext = .homeSearchResults
1197
     var cardContext: JobListingCardContext = .homeSearchResults
1159
 }
1198
 }
1160
 
1199
 
1200
+/// `NSButton` with a tracking area that reports hover transitions and (optionally) swaps in a pointing-hand cursor while hovered.
1201
+private class HoverableButton: NSButton {
1202
+    var hoverHandler: ((Bool) -> Void)?
1203
+    var pointerCursor: Bool = false
1204
+    private(set) var isHovering: Bool = false
1205
+    private var trackingArea: NSTrackingArea?
1206
+    private var didPushCursor: Bool = false
1207
+
1208
+    override func updateTrackingAreas() {
1209
+        super.updateTrackingAreas()
1210
+        if let area = trackingArea { removeTrackingArea(area) }
1211
+        let area = NSTrackingArea(
1212
+            rect: bounds,
1213
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
1214
+            owner: self,
1215
+            userInfo: nil
1216
+        )
1217
+        addTrackingArea(area)
1218
+        trackingArea = area
1219
+    }
1220
+
1221
+    override func mouseEntered(with event: NSEvent) {
1222
+        super.mouseEntered(with: event)
1223
+        isHovering = true
1224
+        hoverHandler?(true)
1225
+        if pointerCursor, !didPushCursor {
1226
+            NSCursor.pointingHand.push()
1227
+            didPushCursor = true
1228
+        }
1229
+    }
1230
+
1231
+    override func mouseExited(with event: NSEvent) {
1232
+        super.mouseExited(with: event)
1233
+        isHovering = false
1234
+        hoverHandler?(false)
1235
+        if didPushCursor {
1236
+            NSCursor.pop()
1237
+            didPushCursor = false
1238
+        }
1239
+    }
1240
+
1241
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
1242
+        super.viewWillMove(toWindow: newWindow)
1243
+        // Guard against an unbalanced cursor stack if the button is removed mid-hover (e.g. job card replaced after a search).
1244
+        if newWindow == nil, didPushCursor {
1245
+            NSCursor.pop()
1246
+            didPushCursor = false
1247
+            isHovering = false
1248
+        }
1249
+    }
1250
+}
1251
+
1252
+/// `NSView` companion to `HoverableButton`: emits hover transitions and can manage a pointing-hand cursor. Used to track hover over composite controls like the gradient "Find jobs" pill.
1253
+private class HoverableView: NSView {
1254
+    var hoverHandler: ((Bool) -> Void)?
1255
+    var pointerCursor: Bool = false
1256
+    private(set) var isHovering: Bool = false
1257
+    private var trackingArea: NSTrackingArea?
1258
+    private var didPushCursor: Bool = false
1259
+
1260
+    override func updateTrackingAreas() {
1261
+        super.updateTrackingAreas()
1262
+        if let area = trackingArea { removeTrackingArea(area) }
1263
+        let area = NSTrackingArea(
1264
+            rect: bounds,
1265
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
1266
+            owner: self,
1267
+            userInfo: nil
1268
+        )
1269
+        addTrackingArea(area)
1270
+        trackingArea = area
1271
+    }
1272
+
1273
+    override func mouseEntered(with event: NSEvent) {
1274
+        super.mouseEntered(with: event)
1275
+        isHovering = true
1276
+        hoverHandler?(true)
1277
+        if pointerCursor, !didPushCursor {
1278
+            NSCursor.pointingHand.push()
1279
+            didPushCursor = true
1280
+        }
1281
+    }
1282
+
1283
+    override func mouseExited(with event: NSEvent) {
1284
+        super.mouseExited(with: event)
1285
+        isHovering = false
1286
+        hoverHandler?(false)
1287
+        if didPushCursor {
1288
+            NSCursor.pop()
1289
+            didPushCursor = false
1290
+        }
1291
+    }
1292
+
1293
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
1294
+        super.viewWillMove(toWindow: newWindow)
1295
+        if newWindow == nil, didPushCursor {
1296
+            NSCursor.pop()
1297
+            didPushCursor = false
1298
+            isHovering = false
1299
+        }
1300
+    }
1301
+}
1302
+
1161
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
1303
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
1162
 private final class JobListingsDocumentView: NSView {
1304
 private final class JobListingsDocumentView: NSView {
1163
     override var isFlipped: Bool { true }
1305
     override var isFlipped: Bool { true }
1164
 }
1306
 }
1165
 
1307
 
1166
-/// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab.
1308
+/// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab. Manages its own hover background so non-selected rows highlight subtly on hover without disturbing the selected-row fill.
1167
 private final class SidebarNavRowView: NSView {
1309
 private final class SidebarNavRowView: NSView {
1168
     private let onSelect: () -> Void
1310
     private let onSelect: () -> Void
1311
+    var restingBackgroundColor: NSColor? {
1312
+        didSet { applyBackground() }
1313
+    }
1314
+    var hoverBackgroundColor: NSColor?
1315
+    private var isHovering: Bool = false
1316
+    private var didPushCursor: Bool = false
1169
 
1317
 
1170
     init(onSelect: @escaping () -> Void) {
1318
     init(onSelect: @escaping () -> Void) {
1171
         self.onSelect = onSelect
1319
         self.onSelect = onSelect
@@ -1200,11 +1348,35 @@ private final class SidebarNavRowView: NSView {
1200
 
1348
 
1201
     override func mouseEntered(with event: NSEvent) {
1349
     override func mouseEntered(with event: NSEvent) {
1202
         super.mouseEntered(with: event)
1350
         super.mouseEntered(with: event)
1203
-        NSCursor.pointingHand.push()
1351
+        isHovering = true
1352
+        applyBackground()
1353
+        if !didPushCursor {
1354
+            NSCursor.pointingHand.push()
1355
+            didPushCursor = true
1356
+        }
1204
     }
1357
     }
1205
 
1358
 
1206
     override func mouseExited(with event: NSEvent) {
1359
     override func mouseExited(with event: NSEvent) {
1207
         super.mouseExited(with: event)
1360
         super.mouseExited(with: event)
1208
-        NSCursor.pop()
1361
+        isHovering = false
1362
+        applyBackground()
1363
+        if didPushCursor {
1364
+            NSCursor.pop()
1365
+            didPushCursor = false
1366
+        }
1367
+    }
1368
+
1369
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
1370
+        super.viewWillMove(toWindow: newWindow)
1371
+        if newWindow == nil, didPushCursor {
1372
+            NSCursor.pop()
1373
+            didPushCursor = false
1374
+            isHovering = false
1375
+        }
1376
+    }
1377
+
1378
+    private func applyBackground() {
1379
+        let color = isHovering ? (hoverBackgroundColor ?? restingBackgroundColor) : restingBackgroundColor
1380
+        layer?.backgroundColor = color?.cgColor
1209
     }
1381
     }
1210
 }
1382
 }