Forráskód Böngészése

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 2 hónapja
szülő
commit
adcd8474ea
1 módosított fájl, 184 hozzáadás és 12 törlés
  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 {
         static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
         /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
         static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
+        /// Hover states: darker brand blue, deeper gradient top, stronger tints, and subtle neutral fills used across CTAs, toggles, and the sidebar.
+        static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
+        static let findJobsCTAHighlightHover = NSColor(srgbRed: 44 / 255, green: 94 / 255, blue: 178 / 255, alpha: 1)
+        static let selectionFillHover = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.2)
+        static let neutralHoverFill = NSColor(srgbRed: 240 / 255, green: 240 / 255, blue: 240 / 255, alpha: 1)
+        static let sidebarRowHoverFill = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.04)
     }
 
     /// Multiline `NSTextField` often ignores `alignment` for wrapped runs; explicit paragraph alignment matches the title.
@@ -64,9 +70,9 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private let searchCard = NSView()
     private let jobSearchIcon = NSImageView()
     private let jobKeywordsField = NSTextField()
-    private let findJobsButton = NSButton()
+    private let findJobsButton = HoverableButton()
     private let findJobsCTAHost = NSView()
-    private let findJobsCTAChrome = NSView()
+    private let findJobsCTAChrome = HoverableView()
     private var findJobsCTAGradientLayer: CAGradientLayer?
     private let jobListingsScrollView = NSScrollView()
     /// 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 {
         applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
         applyButton.contentTintColor = Theme.proCTAText
         applyButton.focusRingType = .none
+        applyButton.pointerCursor = true
+        applyButton.hoverHandler = { [weak applyButton] hovering in
+            applyButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.brandBlue).cgColor
+        }
         applyButton.setContentHuggingPriority(.required, for: .horizontal)
         applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
 
@@ -424,6 +434,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
         savedButton.focusRingType = .none
         savedButton.state = savedOn ? .on : .off
+        savedButton.pointerCursor = true
+        savedButton.hoverHandler = { [weak self, weak savedButton] _ in
+            guard let savedButton = savedButton else { return }
+            self?.styleJobSavedButton(savedButton)
+        }
         styleJobSavedButton(savedButton)
         savedButton.setContentHuggingPriority(.required, for: .horizontal)
         savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
@@ -442,6 +457,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         dismissButton.action = #selector(didTapJobDismiss(_:))
         dismissButton.toolTip = context == .savedJobsPage ? "Remove from saved" : "Dismiss"
         dismissButton.focusRingType = .none
+        dismissButton.wantsLayer = true
+        dismissButton.layer?.cornerRadius = 14
+        dismissButton.layer?.backgroundColor = NSColor.clear.cgColor
+        dismissButton.pointerCursor = true
+        dismissButton.hoverHandler = { [weak dismissButton] hovering in
+            dismissButton?.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : NSColor.clear).cgColor
+            dismissButton?.contentTintColor = hovering ? Theme.primaryText : Theme.secondaryText
+        }
         dismissButton.setContentHuggingPriority(.required, for: .horizontal)
 
         let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
@@ -500,13 +523,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         button.wantsLayer = true
         button.layer?.cornerRadius = 6
         let on = button.state == .on
+        let hovering = (button as? HoverableButton)?.isHovering ?? false
         if on {
-            button.layer?.backgroundColor = Theme.selectionFill.cgColor
+            button.layer?.backgroundColor = (hovering ? Theme.selectionFillHover : Theme.selectionFill).cgColor
             button.layer?.borderWidth = 1
             button.layer?.borderColor = Theme.brandBlue.cgColor
             button.contentTintColor = Theme.brandBlue
         } else {
-            button.layer?.backgroundColor = Theme.cardBackground.cgColor
+            button.layer?.backgroundColor = (hovering ? Theme.neutralHoverFill : Theme.cardBackground).cgColor
             button.layer?.borderWidth = 1
             button.layer?.borderColor = Theme.border.cgColor
             button.contentTintColor = Theme.primaryText
@@ -626,6 +650,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         findJobsCTAChrome.layer?.addSublayer(gradient)
         findJobsCTAGradientLayer = gradient
 
+        // 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.
+        findJobsCTAChrome.pointerCursor = true
+        findJobsCTAChrome.hoverHandler = { [weak self] hovering in
+            guard let layer = self?.findJobsCTAGradientLayer else { return }
+            CATransaction.begin()
+            CATransaction.setAnimationDuration(0.15)
+            layer.colors = hovering
+                ? [Theme.findJobsCTAHighlightHover.cgColor, Theme.brandBlueHover.cgColor]
+                : [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
+            CATransaction.commit()
+        }
+
         findJobsButton.translatesAutoresizingMaskIntoConstraints = false
         findJobsButton.title = ""
         findJobsButton.attributedTitle = NSAttributedString(
@@ -977,9 +1013,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
             rowHost.translatesAutoresizingMaskIntoConstraints = false
             rowHost.wantsLayer = true
             rowHost.layer?.cornerRadius = 8
-            if isSelected {
-                rowHost.layer?.backgroundColor = Theme.selectionFill.cgColor
-            }
+            rowHost.restingBackgroundColor = isSelected ? Theme.selectionFill : nil
+            rowHost.hoverBackgroundColor = isSelected ? Theme.selectionFillHover : Theme.sidebarRowHoverFill
             rowHost.setAccessibilityLabel(item.title)
             rowHost.setAccessibilityRole(.button)
             rowHost.setAccessibilitySelected(isSelected)
@@ -1089,7 +1124,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         let innerContentWidth = cardWidth - 28
         upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
 
-        let upgradeButton = NSButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
+        let upgradeButton = HoverableButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
         upgradeButton.isBordered = false
         upgradeButton.bezelStyle = .rounded
         upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
@@ -1100,6 +1135,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         upgradeButton.layer?.cornerRadius = 20
         upgradeButton.translatesAutoresizingMaskIntoConstraints = false
         upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
+        upgradeButton.pointerCursor = true
+        upgradeButton.hoverHandler = { [weak upgradeButton] hovering in
+            upgradeButton?.layer?.backgroundColor = (hovering ? Theme.brandBlueHover : Theme.proCTABackground).cgColor
+        }
 
         inner.addArrangedSubview(eyebrowRow)
         inner.addArrangedSubview(headline)
@@ -1153,19 +1192,128 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 }
 
 /// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
-private final class JobPayloadButton: NSButton {
+private final class JobPayloadButton: HoverableButton {
     var jobPayload: JobListing?
     var cardContext: JobListingCardContext = .homeSearchResults
 }
 
+/// `NSButton` with a tracking area that reports hover transitions and (optionally) swaps in a pointing-hand cursor while hovered.
+private class HoverableButton: NSButton {
+    var hoverHandler: ((Bool) -> Void)?
+    var pointerCursor: Bool = false
+    private(set) var isHovering: Bool = false
+    private var trackingArea: NSTrackingArea?
+    private var didPushCursor: Bool = false
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        if let area = trackingArea { removeTrackingArea(area) }
+        let area = NSTrackingArea(
+            rect: bounds,
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        addTrackingArea(area)
+        trackingArea = area
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        super.mouseEntered(with: event)
+        isHovering = true
+        hoverHandler?(true)
+        if pointerCursor, !didPushCursor {
+            NSCursor.pointingHand.push()
+            didPushCursor = true
+        }
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        super.mouseExited(with: event)
+        isHovering = false
+        hoverHandler?(false)
+        if didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+        }
+    }
+
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
+        super.viewWillMove(toWindow: newWindow)
+        // Guard against an unbalanced cursor stack if the button is removed mid-hover (e.g. job card replaced after a search).
+        if newWindow == nil, didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+            isHovering = false
+        }
+    }
+}
+
+/// `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.
+private class HoverableView: NSView {
+    var hoverHandler: ((Bool) -> Void)?
+    var pointerCursor: Bool = false
+    private(set) var isHovering: Bool = false
+    private var trackingArea: NSTrackingArea?
+    private var didPushCursor: Bool = false
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        if let area = trackingArea { removeTrackingArea(area) }
+        let area = NSTrackingArea(
+            rect: bounds,
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
+            owner: self,
+            userInfo: nil
+        )
+        addTrackingArea(area)
+        trackingArea = area
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        super.mouseEntered(with: event)
+        isHovering = true
+        hoverHandler?(true)
+        if pointerCursor, !didPushCursor {
+            NSCursor.pointingHand.push()
+            didPushCursor = true
+        }
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        super.mouseExited(with: event)
+        isHovering = false
+        hoverHandler?(false)
+        if didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+        }
+    }
+
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
+        super.viewWillMove(toWindow: newWindow)
+        if newWindow == nil, didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+            isHovering = false
+        }
+    }
+}
+
 /// 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).
 private final class JobListingsDocumentView: NSView {
     override var isFlipped: Bool { true }
 }
 
-/// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab.
+/// 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.
 private final class SidebarNavRowView: NSView {
     private let onSelect: () -> Void
+    var restingBackgroundColor: NSColor? {
+        didSet { applyBackground() }
+    }
+    var hoverBackgroundColor: NSColor?
+    private var isHovering: Bool = false
+    private var didPushCursor: Bool = false
 
     init(onSelect: @escaping () -> Void) {
         self.onSelect = onSelect
@@ -1200,11 +1348,35 @@ private final class SidebarNavRowView: NSView {
 
     override func mouseEntered(with event: NSEvent) {
         super.mouseEntered(with: event)
-        NSCursor.pointingHand.push()
+        isHovering = true
+        applyBackground()
+        if !didPushCursor {
+            NSCursor.pointingHand.push()
+            didPushCursor = true
+        }
     }
 
     override func mouseExited(with event: NSEvent) {
         super.mouseExited(with: event)
-        NSCursor.pop()
+        isHovering = false
+        applyBackground()
+        if didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+        }
+    }
+
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
+        super.viewWillMove(toWindow: newWindow)
+        if newWindow == nil, didPushCursor {
+            NSCursor.pop()
+            didPushCursor = false
+            isHovering = false
+        }
+    }
+
+    private func applyBackground() {
+        let color = isHovering ? (hoverBackgroundColor ?? restingBackgroundColor) : restingBackgroundColor
+        layer?.backgroundColor = color?.cgColor
     }
 }