|
|
@@ -50,11 +50,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
private let findJobsCTAChrome = NSView()
|
|
|
private var findJobsCTAGradientLayer: CAGradientLayer?
|
|
|
private let jobListingsScrollView = NSScrollView()
|
|
|
- private let jobListingsContainer = NSView()
|
|
|
+ /// Flipped so short result lists stay visually under the search bar instead of leaving a gap above the cards.
|
|
|
+ private let jobListingsContainer = JobListingsDocumentView()
|
|
|
private let jobListingsStack = NSStackView()
|
|
|
|
|
|
private var currentSidebarItems: [SidebarItem] = []
|
|
|
private var selectedSidebarIndex: Int = 0
|
|
|
+ /// Full list from `DashboardData`; results are shown after the user runs a search.
|
|
|
+ private var catalogJobListings: [JobListing] = []
|
|
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
|
super.init(frame: frameRect)
|
|
|
@@ -82,7 +85,8 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
selectedSidebarIndex = max(0, currentSidebarItems.count - 1)
|
|
|
}
|
|
|
configureSidebar()
|
|
|
- configureJobListings(data.jobListings)
|
|
|
+ catalogJobListings = data.jobListings
|
|
|
+ configureJobListings([], noResultsForQuery: nil)
|
|
|
}
|
|
|
|
|
|
private func setupLayout() {
|
|
|
@@ -157,7 +161,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
let listingsTopSpacer = NSView()
|
|
|
listingsTopSpacer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- listingsTopSpacer.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
|
+ listingsTopSpacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
|
|
|
|
|
|
jobListingsContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
jobListingsStack.orientation = .vertical
|
|
|
@@ -248,11 +252,22 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func configureJobListings(_ jobs: [JobListing]) {
|
|
|
+ private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?) {
|
|
|
jobListingsStack.arrangedSubviews.forEach {
|
|
|
jobListingsStack.removeArrangedSubview($0)
|
|
|
$0.removeFromSuperview()
|
|
|
}
|
|
|
+ if jobs.isEmpty, let query = noResultsForQuery, !query.isEmpty {
|
|
|
+ let empty = NSTextField(wrappingLabelWithString: "No jobs match “\(query)”. Try different keywords or browse the full list with an empty search.")
|
|
|
+ empty.font = .systemFont(ofSize: 14, weight: .regular)
|
|
|
+ empty.textColor = Theme.secondaryText
|
|
|
+ empty.alignment = .center
|
|
|
+ empty.maximumNumberOfLines = 0
|
|
|
+ empty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ jobListingsStack.addArrangedSubview(empty)
|
|
|
+ empty.widthAnchor.constraint(equalTo: jobListingsStack.widthAnchor).isActive = true
|
|
|
+ return
|
|
|
+ }
|
|
|
for job in jobs {
|
|
|
let card = makeJobListingCard(job)
|
|
|
jobListingsStack.addArrangedSubview(card)
|
|
|
@@ -483,7 +498,25 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
}
|
|
|
|
|
|
@objc private func didSubmitSearch() {
|
|
|
- // Hook up search submission here when wiring up real data.
|
|
|
+ let query = jobKeywordsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let results = jobsMatchingSearch(query: query, in: catalogJobListings)
|
|
|
+ let noResultsMessage: String? = (!results.isEmpty || query.isEmpty) ? nil : query
|
|
|
+ configureJobListings(results, noResultsForQuery: noResultsMessage)
|
|
|
+ window?.makeFirstResponder(nil)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Each whitespace-separated token must appear in the title or description (case-insensitive). Empty query returns the full catalog.
|
|
|
+ private func jobsMatchingSearch(query: String, in jobs: [JobListing]) -> [JobListing] {
|
|
|
+ let tokens = query
|
|
|
+ .lowercased()
|
|
|
+ .split(whereSeparator: { $0.isWhitespace })
|
|
|
+ .map(String.init)
|
|
|
+ .filter { !$0.isEmpty }
|
|
|
+ guard !tokens.isEmpty else { return jobs }
|
|
|
+ return jobs.filter { job in
|
|
|
+ let haystack = "\(job.title) \(job.description)".lowercased()
|
|
|
+ return tokens.allSatisfy { haystack.contains($0) }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
@@ -494,6 +527,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
applySearchFieldInsertionPoint(obj.object)
|
|
|
}
|
|
|
|
|
|
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
|
+ guard control === jobKeywordsField, commandSelector == #selector(NSResponder.insertNewline(_:)) else {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ didSubmitSearch()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
private func applySearchFieldInsertionPoint(_ object: Any?) {
|
|
|
guard let field = object as? NSTextField,
|
|
|
field === jobKeywordsField,
|
|
|
@@ -692,6 +733,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
|
|
|
|
|
|
}
|
|
|
|
|
|
+/// 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.
|
|
|
private final class SidebarNavRowView: NSView {
|
|
|
private let onSelect: () -> Void
|