|
|
@@ -5,7 +5,7 @@
|
|
5
|
5
|
|
|
6
|
6
|
import Cocoa
|
|
7
|
7
|
|
|
8
|
|
-final class DashboardView: NSView {
|
|
|
8
|
+final class DashboardView: NSView, NSTextFieldDelegate {
|
|
9
|
9
|
/// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders).
|
|
10
|
10
|
private enum Theme {
|
|
11
|
11
|
static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
|
|
|
@@ -13,6 +13,8 @@ final class DashboardView: NSView {
|
|
13
|
13
|
static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
|
|
14
|
14
|
static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
15
|
15
|
static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
|
16
|
+ /// Subtitle on the welcome hero: readable blue-gray aligned with brand.
|
|
|
17
|
+ static let welcomeSubtitleText = NSColor(srgbRed: 52 / 255, green: 92 / 255, blue: 142 / 255, alpha: 1)
|
|
16
|
18
|
static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12)
|
|
17
|
19
|
static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
|
|
18
|
20
|
static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
|
|
|
@@ -20,6 +22,8 @@ final class DashboardView: NSView {
|
|
20
|
22
|
static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
21
|
23
|
static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
|
|
22
|
24
|
static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
|
25
|
+ /// Job search bar outer stroke (charcoal).
|
|
|
26
|
+ static let searchBarBorder = NSColor(srgbRed: 58 / 255, green: 58 / 255, blue: 58 / 255, alpha: 1)
|
|
23
|
27
|
static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1)
|
|
24
|
28
|
static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
|
|
25
|
29
|
static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
|
|
|
@@ -35,9 +39,14 @@ final class DashboardView: NSView {
|
|
35
|
39
|
private let mainOverlay = NSStackView()
|
|
36
|
40
|
private let greetingLabel = NSTextField(labelWithString: "")
|
|
37
|
41
|
private let subtitleLabel = NSTextField(labelWithString: "")
|
|
|
42
|
+ private let searchBarShadowHost = NSView()
|
|
38
|
43
|
private let searchCard = NSView()
|
|
39
|
|
- private let searchIcon = NSImageView()
|
|
40
|
|
- private let searchField = NSTextField()
|
|
|
44
|
+ private let jobSearchIcon = NSImageView()
|
|
|
45
|
+ private let jobKeywordsField = NSTextField()
|
|
|
46
|
+ private let searchDivider = NSView()
|
|
|
47
|
+ private let locationIcon = NSImageView()
|
|
|
48
|
+ private let locationField = NSTextField()
|
|
|
49
|
+ private let findJobsButton = NSButton()
|
|
41
|
50
|
private let scrollView = NSScrollView()
|
|
42
|
51
|
|
|
43
|
52
|
private var currentSidebarItems: [SidebarItem] = []
|
|
|
@@ -56,6 +65,7 @@ final class DashboardView: NSView {
|
|
56
|
65
|
override func layout() {
|
|
57
|
66
|
super.layout()
|
|
58
|
67
|
updateDocumentLayout()
|
|
|
68
|
+ updateSearchBarShadowPath()
|
|
59
|
69
|
}
|
|
60
|
70
|
|
|
61
|
71
|
func render(_ data: DashboardData) {
|
|
|
@@ -124,12 +134,12 @@ final class DashboardView: NSView {
|
|
124
|
134
|
mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
125
|
135
|
|
|
126
|
136
|
greetingLabel.font = .systemFont(ofSize: 32, weight: .bold)
|
|
127
|
|
- greetingLabel.textColor = Theme.primaryText
|
|
|
137
|
+ greetingLabel.textColor = Theme.brandBlue
|
|
128
|
138
|
greetingLabel.alignment = .center
|
|
129
|
139
|
greetingLabel.maximumNumberOfLines = 1
|
|
130
|
140
|
|
|
131
|
141
|
subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
|
|
132
|
|
- subtitleLabel.textColor = Theme.secondaryText
|
|
|
142
|
+ subtitleLabel.textColor = Theme.welcomeSubtitleText
|
|
133
|
143
|
subtitleLabel.alignment = .center
|
|
134
|
144
|
subtitleLabel.maximumNumberOfLines = 2
|
|
135
|
145
|
|
|
|
@@ -137,7 +147,7 @@ final class DashboardView: NSView {
|
|
137
|
147
|
topInset.translatesAutoresizingMaskIntoConstraints = false
|
|
138
|
148
|
topInset.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
|
139
|
149
|
|
|
140
|
|
- configureSearchCard()
|
|
|
150
|
+ configureSearchBar()
|
|
141
|
151
|
|
|
142
|
152
|
let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel])
|
|
143
|
153
|
titleBlock.orientation = .vertical
|
|
|
@@ -156,7 +166,7 @@ final class DashboardView: NSView {
|
|
156
|
166
|
mainOverlay.addArrangedSubview(topInset)
|
|
157
|
167
|
mainOverlay.addArrangedSubview(titleBlock)
|
|
158
|
168
|
mainOverlay.addArrangedSubview(midSpacer)
|
|
159
|
|
- mainOverlay.addArrangedSubview(searchCard)
|
|
|
169
|
+ mainOverlay.addArrangedSubview(searchBarShadowHost)
|
|
160
|
170
|
mainOverlay.addArrangedSubview(overlayBottomSpacer)
|
|
161
|
171
|
|
|
162
|
172
|
contentStack.addArrangedSubview(sidebar)
|
|
|
@@ -186,7 +196,7 @@ final class DashboardView: NSView {
|
|
186
|
196
|
mainOverlay.topAnchor.constraint(equalTo: mainHost.topAnchor),
|
|
187
|
197
|
mainOverlay.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
|
|
188
|
198
|
|
|
189
|
|
- searchCard.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.82),
|
|
|
199
|
+ searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
|
|
190
|
200
|
|
|
191
|
201
|
greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 24),
|
|
192
|
202
|
greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -24),
|
|
|
@@ -195,60 +205,166 @@ final class DashboardView: NSView {
|
|
195
|
205
|
])
|
|
196
|
206
|
}
|
|
197
|
207
|
|
|
198
|
|
- private func configureSearchCard() {
|
|
|
208
|
+ private func configureSearchBar() {
|
|
|
209
|
+ let pillCorner: CGFloat = 27
|
|
|
210
|
+ let barHeight: CGFloat = 54
|
|
|
211
|
+
|
|
|
212
|
+ searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false
|
|
|
213
|
+ searchBarShadowHost.wantsLayer = true
|
|
|
214
|
+ searchBarShadowHost.layer?.masksToBounds = false
|
|
|
215
|
+ searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(0.18).cgColor
|
|
|
216
|
+ searchBarShadowHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
|
|
|
217
|
+ searchBarShadowHost.layer?.shadowRadius = 10
|
|
|
218
|
+ searchBarShadowHost.layer?.shadowOpacity = 1
|
|
|
219
|
+ searchBarShadowHost.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
220
|
+
|
|
|
221
|
+ searchCard.translatesAutoresizingMaskIntoConstraints = false
|
|
199
|
222
|
searchCard.wantsLayer = true
|
|
200
|
223
|
searchCard.layer?.backgroundColor = Theme.cardBackground.cgColor
|
|
201
|
|
- searchCard.layer?.cornerRadius = 14
|
|
|
224
|
+ searchCard.layer?.cornerRadius = pillCorner
|
|
202
|
225
|
searchCard.layer?.borderWidth = 1
|
|
203
|
|
- searchCard.layer?.borderColor = Theme.border.cgColor
|
|
204
|
|
- searchCard.translatesAutoresizingMaskIntoConstraints = false
|
|
205
|
|
- searchCard.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
|
206
|
|
-
|
|
207
|
|
- searchIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
208
|
|
- searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
|
209
|
|
- searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
|
|
210
|
|
- searchIcon.contentTintColor = Theme.secondaryText
|
|
211
|
|
-
|
|
212
|
|
- searchField.translatesAutoresizingMaskIntoConstraints = false
|
|
213
|
|
- searchField.isBordered = false
|
|
214
|
|
- searchField.drawsBackground = false
|
|
215
|
|
- searchField.focusRingType = .none
|
|
216
|
|
- searchField.font = .systemFont(ofSize: 14, weight: .regular)
|
|
217
|
|
- searchField.textColor = Theme.primaryText
|
|
218
|
|
- searchField.placeholderAttributedString = NSAttributedString(
|
|
219
|
|
- string: "Search jobs, companies, or locations",
|
|
220
|
|
- attributes: [
|
|
221
|
|
- .foregroundColor: Theme.tertiaryText,
|
|
222
|
|
- .font: NSFont.systemFont(ofSize: 14, weight: .regular)
|
|
223
|
|
- ]
|
|
224
|
|
- )
|
|
225
|
|
- searchField.cell?.usesSingleLineMode = true
|
|
226
|
|
- searchField.cell?.wraps = false
|
|
227
|
|
- searchField.cell?.isScrollable = true
|
|
228
|
|
- searchField.target = self
|
|
229
|
|
- searchField.action = #selector(didSubmitSearch)
|
|
|
226
|
+ searchCard.layer?.borderColor = Theme.searchBarBorder.cgColor
|
|
|
227
|
+ searchCard.layer?.masksToBounds = true
|
|
|
228
|
+
|
|
|
229
|
+ searchBarShadowHost.addSubview(searchCard)
|
|
|
230
|
+
|
|
|
231
|
+ func configureField(_ field: NSTextField, placeholder: String) {
|
|
|
232
|
+ field.translatesAutoresizingMaskIntoConstraints = false
|
|
|
233
|
+ field.isBordered = false
|
|
|
234
|
+ field.drawsBackground = false
|
|
|
235
|
+ field.focusRingType = .none
|
|
|
236
|
+ field.font = .systemFont(ofSize: 14, weight: .regular)
|
|
|
237
|
+ field.textColor = Theme.primaryText
|
|
|
238
|
+ field.delegate = self
|
|
|
239
|
+ field.placeholderAttributedString = NSAttributedString(
|
|
|
240
|
+ string: placeholder,
|
|
|
241
|
+ attributes: [
|
|
|
242
|
+ .foregroundColor: Theme.secondaryText,
|
|
|
243
|
+ .font: NSFont.systemFont(ofSize: 14, weight: .regular)
|
|
|
244
|
+ ]
|
|
|
245
|
+ )
|
|
|
246
|
+ field.cell?.usesSingleLineMode = true
|
|
|
247
|
+ field.cell?.wraps = false
|
|
|
248
|
+ field.cell?.isScrollable = true
|
|
|
249
|
+ field.target = self
|
|
|
250
|
+ field.action = #selector(didSubmitSearch)
|
|
|
251
|
+ }
|
|
230
|
252
|
|
|
231
|
|
- searchCard.addSubview(searchIcon)
|
|
232
|
|
- searchCard.addSubview(searchField)
|
|
|
253
|
+ jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
254
|
+ jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
|
|
255
|
+ jobSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Job search")
|
|
|
256
|
+ jobSearchIcon.contentTintColor = Theme.primaryText
|
|
|
257
|
+
|
|
|
258
|
+ configureField(jobKeywordsField, placeholder: "Job title, keywords, or company")
|
|
|
259
|
+
|
|
|
260
|
+ searchDivider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
261
|
+ searchDivider.wantsLayer = true
|
|
|
262
|
+ searchDivider.layer?.backgroundColor = Theme.border.cgColor
|
|
|
263
|
+
|
|
|
264
|
+ locationIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
265
|
+ locationIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
|
|
|
266
|
+ locationIcon.image = NSImage(systemSymbolName: "mappin.and.ellipse", accessibilityDescription: "Location")
|
|
|
267
|
+ locationIcon.contentTintColor = Theme.primaryText
|
|
|
268
|
+
|
|
|
269
|
+ configureField(locationField, placeholder: "City, state, zip code, or \"remote\"")
|
|
|
270
|
+
|
|
|
271
|
+ findJobsButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
272
|
+ findJobsButton.title = "Find jobs"
|
|
|
273
|
+ findJobsButton.isBordered = false
|
|
|
274
|
+ findJobsButton.bezelStyle = .rounded
|
|
|
275
|
+ findJobsButton.font = .systemFont(ofSize: 14, weight: .bold)
|
|
|
276
|
+ findJobsButton.contentTintColor = Theme.proCTAText
|
|
|
277
|
+ findJobsButton.wantsLayer = true
|
|
|
278
|
+ findJobsButton.layer?.backgroundColor = Theme.brandBlue.cgColor
|
|
|
279
|
+ findJobsButton.layer?.cornerRadius = 10
|
|
|
280
|
+ findJobsButton.target = self
|
|
|
281
|
+ findJobsButton.action = #selector(didSubmitSearch)
|
|
|
282
|
+ findJobsButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
283
|
+ findJobsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
284
|
+
|
|
|
285
|
+ let keywordsStack = NSStackView(views: [jobSearchIcon, jobKeywordsField])
|
|
|
286
|
+ keywordsStack.orientation = .horizontal
|
|
|
287
|
+ keywordsStack.spacing = 10
|
|
|
288
|
+ keywordsStack.alignment = .centerY
|
|
|
289
|
+ keywordsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
290
|
+ keywordsStack.edgeInsets = NSEdgeInsets(top: 0, left: 18, bottom: 0, right: 10)
|
|
|
291
|
+
|
|
|
292
|
+ let locationStack = NSStackView(views: [locationIcon, locationField])
|
|
|
293
|
+ locationStack.orientation = .horizontal
|
|
|
294
|
+ locationStack.spacing = 10
|
|
|
295
|
+ locationStack.alignment = .centerY
|
|
|
296
|
+ locationStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
297
|
+ locationStack.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
|
|
|
298
|
+
|
|
|
299
|
+ let row = NSStackView(views: [keywordsStack, searchDivider, locationStack, findJobsButton])
|
|
|
300
|
+ row.orientation = .horizontal
|
|
|
301
|
+ row.spacing = 0
|
|
|
302
|
+ row.alignment = .centerY
|
|
|
303
|
+ row.distribution = .fill
|
|
|
304
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
305
|
+ row.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 10)
|
|
|
306
|
+
|
|
|
307
|
+ searchCard.addSubview(row)
|
|
233
|
308
|
|
|
234
|
309
|
NSLayoutConstraint.activate([
|
|
235
|
|
- searchCard.heightAnchor.constraint(equalToConstant: 48),
|
|
|
310
|
+ searchCard.leadingAnchor.constraint(equalTo: searchBarShadowHost.leadingAnchor),
|
|
|
311
|
+ searchCard.trailingAnchor.constraint(equalTo: searchBarShadowHost.trailingAnchor),
|
|
|
312
|
+ searchCard.topAnchor.constraint(equalTo: searchBarShadowHost.topAnchor),
|
|
|
313
|
+ searchCard.bottomAnchor.constraint(equalTo: searchBarShadowHost.bottomAnchor),
|
|
|
314
|
+
|
|
|
315
|
+ searchBarShadowHost.heightAnchor.constraint(equalToConstant: barHeight),
|
|
|
316
|
+
|
|
|
317
|
+ row.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor),
|
|
|
318
|
+ row.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor),
|
|
|
319
|
+ row.topAnchor.constraint(equalTo: searchCard.topAnchor),
|
|
|
320
|
+ row.bottomAnchor.constraint(equalTo: searchCard.bottomAnchor),
|
|
|
321
|
+
|
|
|
322
|
+ jobSearchIcon.widthAnchor.constraint(equalToConstant: 18),
|
|
|
323
|
+ jobSearchIcon.heightAnchor.constraint(equalToConstant: 18),
|
|
|
324
|
+ locationIcon.widthAnchor.constraint(equalToConstant: 18),
|
|
|
325
|
+ locationIcon.heightAnchor.constraint(equalToConstant: 18),
|
|
|
326
|
+
|
|
|
327
|
+ searchDivider.widthAnchor.constraint(equalToConstant: 1),
|
|
|
328
|
+ searchDivider.heightAnchor.constraint(equalToConstant: 30),
|
|
236
|
329
|
|
|
237
|
|
- searchIcon.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor, constant: 16),
|
|
238
|
|
- searchIcon.centerYAnchor.constraint(equalTo: searchCard.centerYAnchor),
|
|
239
|
|
- searchIcon.widthAnchor.constraint(equalToConstant: 18),
|
|
240
|
|
- searchIcon.heightAnchor.constraint(equalToConstant: 18),
|
|
|
330
|
+ keywordsStack.widthAnchor.constraint(equalTo: locationStack.widthAnchor),
|
|
241
|
331
|
|
|
242
|
|
- searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
|
|
243
|
|
- searchField.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor, constant: -16),
|
|
244
|
|
- searchField.centerYAnchor.constraint(equalTo: searchCard.centerYAnchor)
|
|
|
332
|
+ findJobsButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
333
|
+ findJobsButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 108)
|
|
245
|
334
|
])
|
|
246
|
335
|
}
|
|
247
|
336
|
|
|
|
337
|
+ private func updateSearchBarShadowPath() {
|
|
|
338
|
+ guard searchBarShadowHost.bounds.width > 0, searchBarShadowHost.bounds.height > 0 else { return }
|
|
|
339
|
+ let r = searchBarShadowHost.bounds
|
|
|
340
|
+ let radius = min(r.height / 2, 27)
|
|
|
341
|
+ searchBarShadowHost.layer?.shadowPath = CGPath(
|
|
|
342
|
+ roundedRect: r,
|
|
|
343
|
+ cornerWidth: radius,
|
|
|
344
|
+ cornerHeight: radius,
|
|
|
345
|
+ transform: nil
|
|
|
346
|
+ )
|
|
|
347
|
+ }
|
|
|
348
|
+
|
|
248
|
349
|
@objc private func didSubmitSearch() {
|
|
249
|
350
|
// Hook up search submission here when wiring up real data.
|
|
250
|
351
|
}
|
|
251
|
352
|
|
|
|
353
|
+ func controlTextDidBeginEditing(_ obj: Notification) {
|
|
|
354
|
+ applySearchFieldInsertionPoint(obj.object)
|
|
|
355
|
+ }
|
|
|
356
|
+
|
|
|
357
|
+ func controlTextDidChange(_ obj: Notification) {
|
|
|
358
|
+ applySearchFieldInsertionPoint(obj.object)
|
|
|
359
|
+ }
|
|
|
360
|
+
|
|
|
361
|
+ private func applySearchFieldInsertionPoint(_ object: Any?) {
|
|
|
362
|
+ guard let field = object as? NSTextField,
|
|
|
363
|
+ field === jobKeywordsField || field === locationField,
|
|
|
364
|
+ let textView = field.window?.fieldEditor(true, for: field) as? NSTextView else { return }
|
|
|
365
|
+ textView.insertionPointColor = Theme.primaryText
|
|
|
366
|
+ }
|
|
|
367
|
+
|
|
252
|
368
|
private func configureSidebar() {
|
|
253
|
369
|
let items = currentSidebarItems
|
|
254
|
370
|
sidebar.arrangedSubviews.forEach {
|