Ver código fonte

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 semanas atrás
pai
commit
adcd8474ea
1 arquivos alterados com 184 adições e 12 exclusões
  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 37
         static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
38 38
         /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
39 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 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 70
     private let searchCard = NSView()
65 71
     private let jobSearchIcon = NSImageView()
66 72
     private let jobKeywordsField = NSTextField()
67
-    private let findJobsButton = NSButton()
73
+    private let findJobsButton = HoverableButton()
68 74
     private let findJobsCTAHost = NSView()
69
-    private let findJobsCTAChrome = NSView()
75
+    private let findJobsCTAChrome = HoverableView()
70 76
     private var findJobsCTAGradientLayer: CAGradientLayer?
71 77
     private let jobListingsScrollView = NSScrollView()
72 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 417
         applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
412 418
         applyButton.contentTintColor = Theme.proCTAText
413 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 424
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
415 425
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
416 426
 
@@ -424,6 +434,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
424 434
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
425 435
         savedButton.focusRingType = .none
426 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 442
         styleJobSavedButton(savedButton)
428 443
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
429 444
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
@@ -442,6 +457,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
442 457
         dismissButton.action = #selector(didTapJobDismiss(_:))
443 458
         dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
444 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 468
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
446 469
 
447 470
         let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
@@ -500,13 +523,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
500 523
         button.wantsLayer = true
501 524
         button.layer?.cornerRadius = 6
502 525
         let on = button.state == .on
526
+        let hovering = (button as? HoverableButton)?.isHovering ?? false
503 527
         if on {
504
-            button.layer?.backgroundColor = Theme.selectionFill.cgColor
528
+            button.layer?.backgroundColor = (hovering ? Theme.selectionFillHover : Theme.selectionFill).cgColor
505 529
             button.layer?.borderWidth = 1
506 530
             button.layer?.borderColor = Theme.brandBlue.cgColor
507 531
             button.contentTintColor = Theme.brandBlue
508 532
         } else {
509
-            button.layer?.backgroundColor = Theme.cardBackground.cgColor
533
+            button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
510 534
             button.layer?.borderWidth = 1
511 535
             button.layer?.borderColor = Theme.border.cgColor
512 536
             button.contentTintColor = Theme.primaryText
@@ -626,6 +650,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
626 650
         findJobsCTAChrome.layer?.addSublayer(gradient)
627 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 665
         findJobsButton.translatesAutoresizingMaskIntoConstraints = false
630 666
         findJobsButton.title = ""
631 667
         findJobsButton.attributedTitle = NSAttributedString(
@@ -977,9 +1013,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
977 1013
             rowHost.translatesAutoresizingMaskIntoConstraints = false
978 1014
             rowHost.wantsLayer = true
979 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 1018
             rowHost.setAccessibilityLabel(item.title)
984 1019
             rowHost.setAccessibilityRole(.button)
985 1020
             rowHost.setAccessibilitySelected(isSelected)
@@ -1089,7 +1124,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1089 1124
         let innerContentWidth = cardWidth - 28
1090 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 1128
         upgradeButton.isBordered = false
1094 1129
         upgradeButton.bezelStyle = .rounded
1095 1130
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
@@ -1100,6 +1135,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1100 1135
         upgradeButton.layer?.cornerRadius = 20
1101 1136
         upgradeButton.translatesAutoresizingMaskIntoConstraints = false
1102 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 1143
         inner.addArrangedSubview(eyebrowRow)
1105 1144
         inner.addArrangedSubview(headline)
@@ -1153,19 +1192,128 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1153 1192
 }
1154 1193
 
1155 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 1196
     var jobPayload: JobListing?
1158 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 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 1304
 private final class JobListingsDocumentView: NSView {
1163 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 1309
 private final class SidebarNavRowView: NSView {
1168 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 1318
     init(onSelect: @escaping () -> Void) {
1171 1319
         self.onSelect = onSelect
@@ -1200,11 +1348,35 @@ private final class SidebarNavRowView: NSView {
1200 1348
 
1201 1349
     override func mouseEntered(with event: NSEvent) {
1202 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 1359
     override func mouseExited(with event: NSEvent) {
1207 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
 }