説明なし

MyProfilePageView.swift 57KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323
  1. //
  2. // MyProfilePageView.swift
  3. // App for Indeed
  4. //
  5. // Light-theme profile editor: card layout, adaptive two-column rows, and
  6. // vertical scrolling when the window is short.
  7. //
  8. import Cocoa
  9. private enum ProfilePagePalette {
  10. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  11. static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  12. static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  13. static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
  14. static let fieldFill = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
  15. static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
  16. static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  17. static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
  18. static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
  19. }
  20. /// Keeps profile text left-aligned and LTR so fields do not collapse to a narrow trailing strip under RTL / natural alignment.
  21. private enum ProfileLayoutEnforcement {
  22. static func applyForcedLTR(to view: NSView) {
  23. view.userInterfaceLayoutDirection = .leftToRight
  24. }
  25. static func applyLeftAlignedTextField(_ field: NSTextField) {
  26. applyForcedLTR(to: field)
  27. field.baseWritingDirection = .leftToRight
  28. field.alignment = .left
  29. if let cell = field.cell as? NSTextFieldCell {
  30. cell.alignment = .left
  31. cell.baseWritingDirection = .leftToRight
  32. }
  33. }
  34. static func leftAlignedParagraphStyle() -> NSParagraphStyle {
  35. let p = NSMutableParagraphStyle()
  36. p.alignment = .left
  37. p.baseWritingDirection = .leftToRight
  38. return p
  39. }
  40. }
  41. /// Two fields side‑by‑side with a true 50/50 split, or stacked full‑width when compact. Avoids `NSStackView` collapsing paired columns to a narrow strip on the trailing edge.
  42. private final class ProfileDualFieldRow: NSView {
  43. private let leftView: NSView
  44. private let rightView: NSView
  45. private let spacing: CGFloat
  46. private var horizontalConstraints: [NSLayoutConstraint] = []
  47. private var verticalConstraints: [NSLayoutConstraint] = []
  48. private var isCompact = false
  49. init(left: NSView, right: NSView, spacing: CGFloat = 12) {
  50. self.leftView = left
  51. self.rightView = right
  52. self.spacing = spacing
  53. super.init(frame: .zero)
  54. translatesAutoresizingMaskIntoConstraints = false
  55. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  56. addSubview(leftView)
  57. addSubview(rightView)
  58. leftView.translatesAutoresizingMaskIntoConstraints = false
  59. rightView.translatesAutoresizingMaskIntoConstraints = false
  60. leftView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  61. rightView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  62. leftView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  63. rightView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  64. horizontalConstraints = [
  65. leftView.leftAnchor.constraint(equalTo: leftAnchor),
  66. leftView.topAnchor.constraint(equalTo: topAnchor),
  67. leftView.bottomAnchor.constraint(equalTo: bottomAnchor),
  68. rightView.rightAnchor.constraint(equalTo: rightAnchor),
  69. rightView.topAnchor.constraint(equalTo: topAnchor),
  70. rightView.bottomAnchor.constraint(equalTo: bottomAnchor),
  71. leftView.rightAnchor.constraint(equalTo: rightView.leftAnchor, constant: -spacing),
  72. leftView.widthAnchor.constraint(equalTo: rightView.widthAnchor)
  73. ]
  74. verticalConstraints = [
  75. leftView.leftAnchor.constraint(equalTo: leftAnchor),
  76. leftView.rightAnchor.constraint(equalTo: rightAnchor),
  77. leftView.topAnchor.constraint(equalTo: topAnchor),
  78. rightView.leftAnchor.constraint(equalTo: leftAnchor),
  79. rightView.rightAnchor.constraint(equalTo: rightAnchor),
  80. rightView.topAnchor.constraint(equalTo: leftView.bottomAnchor, constant: spacing),
  81. rightView.bottomAnchor.constraint(equalTo: bottomAnchor)
  82. ]
  83. NSLayoutConstraint.activate(horizontalConstraints)
  84. }
  85. required init?(coder: NSCoder) {
  86. fatalError("init(coder:) has not been implemented")
  87. }
  88. func setCompact(_ compact: Bool) {
  89. guard compact != isCompact else { return }
  90. isCompact = compact
  91. if compact {
  92. NSLayoutConstraint.deactivate(horizontalConstraints)
  93. NSLayoutConstraint.activate(verticalConstraints)
  94. } else {
  95. NSLayoutConstraint.deactivate(verticalConstraints)
  96. NSLayoutConstraint.activate(horizontalConstraints)
  97. }
  98. }
  99. }
  100. final class MyProfilePageView: NSView {
  101. /// Below this form content width, two-column rows stack vertically.
  102. private static let compactFormWidth: CGFloat = 640
  103. private static let horizontalPageInset: CGFloat = 24
  104. private let scrollView = NSScrollView()
  105. private let documentView = NSView()
  106. private let cardView = NSView()
  107. private let formStack = NSStackView()
  108. private let profileNameField = NSTextField()
  109. private let fullNameField = NSTextField()
  110. private let emailField = NSTextField()
  111. private let phoneField = NSTextField()
  112. private let jobTitleField = NSTextField()
  113. private let addressField = NSTextField()
  114. private let careerField = NSTextField()
  115. private let certificatesField = NSTextField()
  116. private let interestsField = NSTextField()
  117. private let languagesField = NSTextField()
  118. private let referralField = NSTextField()
  119. private let saveButton = ProfilePrimaryButton(title: "Save Profile →", target: nil, action: nil)
  120. private var nameEmailRow: ProfileDualFieldRow!
  121. private var phoneJobRow: ProfileDualFieldRow!
  122. private let workExperienceRowsStack = NSStackView()
  123. private var workExperienceEntries: [WorkExperienceEntryView] = []
  124. private let educationRowsStack = NSStackView()
  125. private var educationEntries: [EducationEntryView] = []
  126. private var lastCompactLayout: Bool?
  127. private var referralHelperLabel: NSTextField?
  128. /// Force left-to-right geometry so profile fields span the full width even when the window uses RTL layout.
  129. override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
  130. get { .leftToRight }
  131. set { super.userInterfaceLayoutDirection = .leftToRight }
  132. }
  133. override init(frame frameRect: NSRect) {
  134. super.init(frame: frameRect)
  135. setup()
  136. }
  137. required init?(coder: NSCoder) {
  138. super.init(coder: coder)
  139. setup()
  140. }
  141. override func layout() {
  142. super.layout()
  143. if let layer = cardView.layer, layer.shadowOpacity > 0 {
  144. let r = layer.cornerRadius
  145. layer.shadowPath = CGPath(roundedRect: cardView.bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  146. }
  147. updateMultilinePreferredLayoutWidths()
  148. applyResponsiveRowsIfNeeded()
  149. }
  150. /// Wrapping `NSTextField`s report a tiny intrinsic width until `preferredMaxLayoutWidth` tracks the chrome width, which otherwise collapses the stack to a narrow trailing column.
  151. private func updateMultilinePreferredLayoutWidths() {
  152. let horizontalInset: CGFloat = 24
  153. applyPreferredWrapWidth(to: profileNameField, horizontalInset: horizontalInset)
  154. applyPreferredWrapWidth(to: fullNameField, horizontalInset: horizontalInset)
  155. applyPreferredWrapWidth(to: emailField, horizontalInset: horizontalInset)
  156. applyPreferredWrapWidth(to: phoneField, horizontalInset: horizontalInset)
  157. applyPreferredWrapWidth(to: jobTitleField, horizontalInset: horizontalInset)
  158. applyPreferredWrapWidth(to: addressField, horizontalInset: horizontalInset)
  159. applyPreferredWrapWidth(to: referralField, horizontalInset: horizontalInset)
  160. applyPreferredWrapWidth(to: careerField, horizontalInset: horizontalInset)
  161. applyPreferredWrapWidth(to: certificatesField, horizontalInset: horizontalInset)
  162. applyPreferredWrapWidth(to: interestsField, horizontalInset: horizontalInset)
  163. applyPreferredWrapWidth(to: languagesField, horizontalInset: horizontalInset)
  164. if let helper = referralHelperLabel, let stack = helper.superview, stack.bounds.width > 2 {
  165. let w = max(1, stack.bounds.width - 8)
  166. if abs(helper.preferredMaxLayoutWidth - w) > 0.5 {
  167. helper.preferredMaxLayoutWidth = w
  168. }
  169. }
  170. }
  171. private func applyPreferredWrapWidth(to field: NSTextField, horizontalInset: CGFloat) {
  172. guard let wrap = field.superview, wrap.bounds.width > 2 else { return }
  173. let w = max(1, wrap.bounds.width - horizontalInset)
  174. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  175. field.preferredMaxLayoutWidth = w
  176. }
  177. }
  178. private func setup() {
  179. wantsLayer = true
  180. layer?.backgroundColor = ProfilePagePalette.pageBackground.cgColor
  181. userInterfaceLayoutDirection = .leftToRight
  182. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  183. scrollView.translatesAutoresizingMaskIntoConstraints = false
  184. scrollView.userInterfaceLayoutDirection = .leftToRight
  185. ProfileLayoutEnforcement.applyForcedLTR(to: scrollView)
  186. scrollView.hasVerticalScroller = true
  187. scrollView.hasHorizontalScroller = false
  188. scrollView.autohidesScrollers = true
  189. scrollView.drawsBackground = false
  190. scrollView.borderType = .noBorder
  191. scrollView.scrollerStyle = .overlay
  192. scrollView.automaticallyAdjustsContentInsets = false
  193. scrollView.contentView.userInterfaceLayoutDirection = .leftToRight
  194. ProfileLayoutEnforcement.applyForcedLTR(to: scrollView.contentView)
  195. if #available(macOS 10.11, *) {
  196. scrollView.horizontalScrollElasticity = .none
  197. }
  198. documentView.translatesAutoresizingMaskIntoConstraints = false
  199. documentView.userInterfaceLayoutDirection = .leftToRight
  200. ProfileLayoutEnforcement.applyForcedLTR(to: documentView)
  201. cardView.translatesAutoresizingMaskIntoConstraints = false
  202. cardView.wantsLayer = true
  203. cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  204. cardView.layer?.cornerRadius = 18
  205. cardView.layer?.borderWidth = 1
  206. cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
  207. cardView.layer?.masksToBounds = false
  208. cardView.layer?.shadowColor = NSColor.black.cgColor
  209. cardView.layer?.shadowOpacity = 0.06
  210. cardView.layer?.shadowRadius = 20
  211. cardView.layer?.shadowOffset = CGSize(width: 0, height: 10)
  212. cardView.userInterfaceLayoutDirection = .leftToRight
  213. ProfileLayoutEnforcement.applyForcedLTR(to: cardView)
  214. if #available(macOS 11.0, *) {
  215. cardView.layer?.cornerCurve = .continuous
  216. }
  217. formStack.translatesAutoresizingMaskIntoConstraints = false
  218. formStack.orientation = .vertical
  219. formStack.alignment = .width
  220. formStack.distribution = .fill
  221. formStack.spacing = 24
  222. formStack.edgeInsets = NSEdgeInsets(top: 32, left: 28, bottom: 32, right: 28)
  223. formStack.userInterfaceLayoutDirection = .leftToRight
  224. ProfileLayoutEnforcement.applyForcedLTR(to: formStack)
  225. formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  226. formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  227. addSubview(scrollView)
  228. scrollView.documentView = documentView
  229. documentView.addSubview(cardView)
  230. cardView.addSubview(formStack)
  231. NSLayoutConstraint.activate([
  232. scrollView.leftAnchor.constraint(equalTo: leftAnchor),
  233. scrollView.rightAnchor.constraint(equalTo: rightAnchor),
  234. scrollView.topAnchor.constraint(equalTo: topAnchor),
  235. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  236. // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
  237. documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
  238. documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
  239. documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
  240. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  241. documentView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: Self.horizontalPageInset),
  242. // Pin both edges so the card always spans the clip width minus insets; a separate width equal to a large constant can conflict with the clip and slide the card to the trailing edge.
  243. cardView.leftAnchor.constraint(equalTo: documentView.leftAnchor, constant: Self.horizontalPageInset),
  244. cardView.rightAnchor.constraint(equalTo: documentView.rightAnchor, constant: -Self.horizontalPageInset),
  245. cardView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: Self.horizontalPageInset),
  246. formStack.leftAnchor.constraint(equalTo: cardView.leftAnchor),
  247. formStack.rightAnchor.constraint(equalTo: cardView.rightAnchor),
  248. formStack.widthAnchor.constraint(equalTo: cardView.widthAnchor),
  249. formStack.topAnchor.constraint(equalTo: cardView.topAnchor),
  250. formStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor)
  251. ])
  252. addFullWidthArrangedSubview(
  253. labeledGroup(title: "Profile Name *", field: profileNameField, placeholder: "Marketing Director Profile")
  254. )
  255. addFullWidthArrangedSubview(sectionHeading("Personal Information"))
  256. let nameGroup = labeledGroup(title: "Full Name *", field: fullNameField, placeholder: "John Doe")
  257. let emailGroup = labeledGroup(title: "Email *", field: emailField, placeholder: "john@example.com")
  258. nameEmailRow = ProfileDualFieldRow(left: nameGroup, right: emailGroup, spacing: 12)
  259. addFullWidthArrangedSubview(nameEmailRow)
  260. let phoneGroup = labeledGroup(title: "Phone", field: phoneField, placeholder: "+1 (555) 123-4567")
  261. let jobGroup = labeledGroup(title: "Job Title *", field: jobTitleField, placeholder: "Software Engineer")
  262. phoneJobRow = ProfileDualFieldRow(left: phoneGroup, right: jobGroup, spacing: 12)
  263. addFullWidthArrangedSubview(phoneJobRow)
  264. addFullWidthArrangedSubview(
  265. labeledGroup(title: "Address", field: addressField, placeholder: "123 Main St, City, State, ZIP")
  266. )
  267. addFullWidthArrangedSubview(careerSummaryBlock())
  268. addFullWidthArrangedSubview(horizontalSeparator())
  269. addFullWidthArrangedSubview(workExperienceSection())
  270. addFullWidthArrangedSubview(horizontalSeparator())
  271. addFullWidthArrangedSubview(educationSection())
  272. addFullWidthArrangedSubview(horizontalSeparator())
  273. addFullWidthArrangedSubview(
  274. multilineProfileBlock(
  275. title: "Certificates / Rewards",
  276. placeholder: "List your certificates and awards...",
  277. field: certificatesField,
  278. minHeight: 100
  279. )
  280. )
  281. addFullWidthArrangedSubview(horizontalSeparator())
  282. addFullWidthArrangedSubview(
  283. multilineProfileBlock(
  284. title: "Interests",
  285. placeholder: "List your interests and hobbies...",
  286. field: interestsField,
  287. minHeight: 100
  288. )
  289. )
  290. addFullWidthArrangedSubview(horizontalSeparator())
  291. addFullWidthArrangedSubview(
  292. multilineProfileBlock(
  293. title: "Languages",
  294. placeholder: "List languages you speak (e.g., English - Native, Spanish - Fluent)...",
  295. field: languagesField,
  296. minHeight: 100
  297. )
  298. )
  299. addFullWidthArrangedSubview(horizontalSeparator())
  300. addFullWidthArrangedSubview(referralBlock())
  301. addFullWidthArrangedSubview(saveButtonHost())
  302. saveButton.target = self
  303. saveButton.action = #selector(didTapSave)
  304. appendWorkExperienceEntry()
  305. appendEducationEntry()
  306. }
  307. private func applyResponsiveRowsIfNeeded() {
  308. let w = cardView.bounds.width
  309. guard w > 1 else { return }
  310. let formWidth = max(0, w - formStack.edgeInsets.left - formStack.edgeInsets.right)
  311. let compact = formWidth < Self.compactFormWidth
  312. guard compact != lastCompactLayout else { return }
  313. lastCompactLayout = compact
  314. nameEmailRow.setCompact(compact)
  315. phoneJobRow.setCompact(compact)
  316. for entry in workExperienceEntries {
  317. entry.applyCompactLayout(compact)
  318. }
  319. for entry in educationEntries {
  320. entry.applyCompactLayout(compact)
  321. }
  322. }
  323. private func addFullWidthArrangedSubview(_ view: NSView) {
  324. formStack.addArrangedSubview(view)
  325. view.setContentHuggingPriority(.defaultLow, for: .horizontal)
  326. view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  327. }
  328. private func sectionHeading(_ text: String) -> NSView {
  329. let label = NSTextField(labelWithString: text)
  330. label.font = .systemFont(ofSize: 15, weight: .semibold)
  331. label.textColor = ProfilePagePalette.primaryText
  332. label.baseWritingDirection = .leftToRight
  333. label.translatesAutoresizingMaskIntoConstraints = false
  334. label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  335. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  336. let spacer = NSView()
  337. spacer.translatesAutoresizingMaskIntoConstraints = false
  338. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  339. let row = NSStackView(views: [label, spacer])
  340. row.orientation = .horizontal
  341. row.alignment = .centerY
  342. row.distribution = .fill
  343. row.spacing = 0
  344. row.userInterfaceLayoutDirection = .leftToRight
  345. ProfileLayoutEnforcement.applyForcedLTR(to: row)
  346. row.translatesAutoresizingMaskIntoConstraints = false
  347. NSLayoutConstraint.activate([
  348. label.leftAnchor.constraint(equalTo: row.leftAnchor)
  349. ])
  350. return row
  351. }
  352. private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
  353. let label = NSTextField(labelWithString: title)
  354. label.font = .systemFont(ofSize: 12, weight: .medium)
  355. label.textColor = ProfilePagePalette.secondaryText
  356. label.translatesAutoresizingMaskIntoConstraints = false
  357. label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  358. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  359. styleSingleLineField(field, placeholder: placeholder)
  360. let wrap = roundedFieldChrome(containing: field, minHeight: 40)
  361. let stack = NSStackView(views: [label, wrap])
  362. stack.orientation = .vertical
  363. stack.spacing = 8
  364. // `.width` stretches label + field chrome to the row width; `.leading` collapses to intrinsic width and caused right-edge compression.
  365. stack.alignment = .width
  366. stack.translatesAutoresizingMaskIntoConstraints = false
  367. stack.userInterfaceLayoutDirection = .leftToRight
  368. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  369. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  370. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  371. wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  372. NSLayoutConstraint.activate([
  373. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor),
  374. label.leftAnchor.constraint(equalTo: stack.leftAnchor)
  375. ])
  376. return stack
  377. }
  378. private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
  379. field.translatesAutoresizingMaskIntoConstraints = false
  380. field.isBordered = false
  381. field.drawsBackground = false
  382. field.focusRingType = .none
  383. field.font = .systemFont(ofSize: 14, weight: .regular)
  384. field.textColor = ProfilePagePalette.primaryText
  385. field.setContentHuggingPriority(.defaultLow, for: .horizontal)
  386. field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  387. let paragraph = ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  388. field.placeholderAttributedString = NSAttributedString(
  389. string: placeholder,
  390. attributes: [
  391. .foregroundColor: ProfilePagePalette.secondaryText,
  392. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  393. .paragraphStyle: paragraph
  394. ]
  395. )
  396. field.cell?.usesSingleLineMode = true
  397. field.cell?.wraps = false
  398. field.cell?.isScrollable = true
  399. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  400. }
  401. private func roundedFieldChrome(containing field: NSTextField, minHeight: CGFloat) -> NSView {
  402. let wrap = NSView()
  403. wrap.translatesAutoresizingMaskIntoConstraints = false
  404. wrap.wantsLayer = true
  405. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  406. wrap.layer?.cornerRadius = 10
  407. wrap.layer?.borderWidth = 1
  408. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  409. if #available(macOS 11.0, *) {
  410. wrap.layer?.cornerCurve = .continuous
  411. }
  412. wrap.addSubview(field)
  413. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  414. NSLayoutConstraint.activate([
  415. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  416. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  417. field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
  418. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  419. ])
  420. return wrap
  421. }
  422. private func careerSummaryBlock() -> NSView {
  423. let label = NSTextField(labelWithString: "Career Summary")
  424. label.font = .systemFont(ofSize: 12, weight: .medium)
  425. label.textColor = ProfilePagePalette.secondaryText
  426. label.translatesAutoresizingMaskIntoConstraints = false
  427. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  428. careerField.translatesAutoresizingMaskIntoConstraints = false
  429. careerField.isEditable = true
  430. careerField.isSelectable = true
  431. careerField.isBordered = false
  432. careerField.drawsBackground = false
  433. careerField.focusRingType = .none
  434. careerField.font = .systemFont(ofSize: 14, weight: .regular)
  435. careerField.textColor = ProfilePagePalette.primaryText
  436. careerField.maximumNumberOfLines = 0
  437. careerField.cell?.wraps = true
  438. careerField.cell?.isScrollable = false
  439. careerField.cell?.usesSingleLineMode = false
  440. careerField.stringValue = ""
  441. careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
  442. careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  443. careerField.placeholderAttributedString = NSAttributedString(
  444. string: "Brief overview of your professional background and key achievements...",
  445. attributes: [
  446. .foregroundColor: ProfilePagePalette.secondaryText,
  447. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  448. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  449. ]
  450. )
  451. ProfileLayoutEnforcement.applyLeftAlignedTextField(careerField)
  452. let wrap = NSView()
  453. wrap.translatesAutoresizingMaskIntoConstraints = false
  454. wrap.wantsLayer = true
  455. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  456. wrap.layer?.cornerRadius = 10
  457. wrap.layer?.borderWidth = 1
  458. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  459. if #available(macOS 11.0, *) {
  460. wrap.layer?.cornerCurve = .continuous
  461. }
  462. wrap.addSubview(careerField)
  463. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  464. NSLayoutConstraint.activate([
  465. careerField.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  466. careerField.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  467. careerField.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  468. careerField.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  469. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: 168)
  470. ])
  471. let stack = NSStackView(views: [label, wrap])
  472. stack.orientation = .vertical
  473. stack.spacing = 8
  474. stack.alignment = .width
  475. stack.translatesAutoresizingMaskIntoConstraints = false
  476. stack.userInterfaceLayoutDirection = .leftToRight
  477. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  478. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  479. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  480. NSLayoutConstraint.activate([
  481. label.leftAnchor.constraint(equalTo: stack.leftAnchor)
  482. ])
  483. return stack
  484. }
  485. private func multilineProfileBlock(title: String, placeholder: String, field: NSTextField, minHeight: CGFloat) -> NSView {
  486. let label = NSTextField(labelWithString: title)
  487. label.font = .systemFont(ofSize: 12, weight: .medium)
  488. label.textColor = ProfilePagePalette.secondaryText
  489. label.translatesAutoresizingMaskIntoConstraints = false
  490. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  491. field.translatesAutoresizingMaskIntoConstraints = false
  492. field.isEditable = true
  493. field.isSelectable = true
  494. field.isBordered = false
  495. field.drawsBackground = false
  496. field.focusRingType = .none
  497. field.font = .systemFont(ofSize: 14, weight: .regular)
  498. field.textColor = ProfilePagePalette.primaryText
  499. field.maximumNumberOfLines = 0
  500. field.cell?.wraps = true
  501. field.cell?.isScrollable = false
  502. field.cell?.usesSingleLineMode = false
  503. field.stringValue = ""
  504. field.setContentHuggingPriority(.defaultLow, for: .horizontal)
  505. field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  506. field.placeholderAttributedString = NSAttributedString(
  507. string: placeholder,
  508. attributes: [
  509. .foregroundColor: ProfilePagePalette.secondaryText,
  510. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  511. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  512. ]
  513. )
  514. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  515. let wrap = NSView()
  516. wrap.translatesAutoresizingMaskIntoConstraints = false
  517. wrap.wantsLayer = true
  518. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  519. wrap.layer?.cornerRadius = 10
  520. wrap.layer?.borderWidth = 1
  521. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  522. if #available(macOS 11.0, *) {
  523. wrap.layer?.cornerCurve = .continuous
  524. }
  525. wrap.addSubview(field)
  526. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  527. NSLayoutConstraint.activate([
  528. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  529. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  530. field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  531. field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  532. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  533. ])
  534. let stack = NSStackView(views: [label, wrap])
  535. stack.orientation = .vertical
  536. stack.spacing = 8
  537. stack.alignment = .width
  538. stack.translatesAutoresizingMaskIntoConstraints = false
  539. stack.userInterfaceLayoutDirection = .leftToRight
  540. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  541. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  542. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  543. NSLayoutConstraint.activate([
  544. label.leftAnchor.constraint(equalTo: stack.leftAnchor)
  545. ])
  546. return stack
  547. }
  548. private func referralBlock() -> NSView {
  549. let label = NSTextField(labelWithString: "Referral (Optional)")
  550. label.font = .systemFont(ofSize: 12, weight: .medium)
  551. label.textColor = ProfilePagePalette.secondaryText
  552. label.translatesAutoresizingMaskIntoConstraints = false
  553. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  554. styleSingleLineField(referralField, placeholder: "Referred by (Company/Person Name)")
  555. let wrap = roundedFieldChrome(containing: referralField, minHeight: 40)
  556. let helper = NSTextField(wrappingLabelWithString: "If someone referred you for this job, enter their name or company here")
  557. helper.font = .systemFont(ofSize: 11, weight: .regular)
  558. helper.textColor = ProfilePagePalette.secondaryText
  559. helper.translatesAutoresizingMaskIntoConstraints = false
  560. ProfileLayoutEnforcement.applyLeftAlignedTextField(helper)
  561. referralHelperLabel = helper
  562. let stack = NSStackView(views: [label, wrap, helper])
  563. stack.orientation = .vertical
  564. stack.spacing = 8
  565. stack.alignment = .width
  566. stack.translatesAutoresizingMaskIntoConstraints = false
  567. stack.userInterfaceLayoutDirection = .leftToRight
  568. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  569. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  570. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  571. if #available(macOS 10.11, *) {
  572. stack.setCustomSpacing(6, after: wrap)
  573. }
  574. NSLayoutConstraint.activate([
  575. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  576. helper.leftAnchor.constraint(equalTo: stack.leftAnchor)
  577. ])
  578. return stack
  579. }
  580. private func horizontalSeparator() -> NSView {
  581. let box = NSBox()
  582. box.boxType = .separator
  583. box.translatesAutoresizingMaskIntoConstraints = false
  584. return box
  585. }
  586. private func workExperienceSection() -> NSView {
  587. let title = NSTextField(labelWithString: "Work Experience")
  588. title.font = .systemFont(ofSize: 15, weight: .semibold)
  589. title.textColor = ProfilePagePalette.primaryText
  590. title.translatesAutoresizingMaskIntoConstraints = false
  591. title.setContentHuggingPriority(.defaultLow, for: .horizontal)
  592. ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
  593. let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddWorkExperience))
  594. addButton.translatesAutoresizingMaskIntoConstraints = false
  595. addButton.bezelStyle = .rounded
  596. addButton.isBordered = true
  597. addButton.font = .systemFont(ofSize: 12, weight: .medium)
  598. addButton.controlSize = .regular
  599. addButton.setContentHuggingPriority(.required, for: .horizontal)
  600. let spacer = NSView()
  601. spacer.translatesAutoresizingMaskIntoConstraints = false
  602. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  603. let headerRow = NSStackView(views: [title, spacer, addButton])
  604. headerRow.orientation = .horizontal
  605. headerRow.alignment = .centerY
  606. headerRow.distribution = .fill
  607. headerRow.spacing = 12
  608. headerRow.userInterfaceLayoutDirection = .leftToRight
  609. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  610. headerRow.translatesAutoresizingMaskIntoConstraints = false
  611. workExperienceRowsStack.translatesAutoresizingMaskIntoConstraints = false
  612. workExperienceRowsStack.orientation = .vertical
  613. workExperienceRowsStack.spacing = 20
  614. workExperienceRowsStack.alignment = .width
  615. workExperienceRowsStack.userInterfaceLayoutDirection = .leftToRight
  616. ProfileLayoutEnforcement.applyForcedLTR(to: workExperienceRowsStack)
  617. let outer = NSStackView(views: [headerRow, workExperienceRowsStack])
  618. outer.orientation = .vertical
  619. outer.spacing = 16
  620. outer.alignment = .width
  621. outer.translatesAutoresizingMaskIntoConstraints = false
  622. outer.userInterfaceLayoutDirection = .leftToRight
  623. ProfileLayoutEnforcement.applyForcedLTR(to: outer)
  624. return outer
  625. }
  626. private func educationSection() -> NSView {
  627. let title = NSTextField(labelWithString: "Education")
  628. title.font = .systemFont(ofSize: 15, weight: .semibold)
  629. title.textColor = ProfilePagePalette.primaryText
  630. title.translatesAutoresizingMaskIntoConstraints = false
  631. title.setContentHuggingPriority(.defaultLow, for: .horizontal)
  632. ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
  633. let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddEducation))
  634. addButton.translatesAutoresizingMaskIntoConstraints = false
  635. addButton.bezelStyle = .rounded
  636. addButton.isBordered = true
  637. addButton.font = .systemFont(ofSize: 12, weight: .medium)
  638. addButton.setContentHuggingPriority(.required, for: .horizontal)
  639. let spacer = NSView()
  640. spacer.translatesAutoresizingMaskIntoConstraints = false
  641. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  642. let headerRow = NSStackView(views: [title, spacer, addButton])
  643. headerRow.orientation = .horizontal
  644. headerRow.alignment = .centerY
  645. headerRow.distribution = .fill
  646. headerRow.spacing = 12
  647. headerRow.userInterfaceLayoutDirection = .leftToRight
  648. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  649. headerRow.translatesAutoresizingMaskIntoConstraints = false
  650. educationRowsStack.translatesAutoresizingMaskIntoConstraints = false
  651. educationRowsStack.orientation = .vertical
  652. educationRowsStack.spacing = 16
  653. educationRowsStack.alignment = .width
  654. educationRowsStack.userInterfaceLayoutDirection = .leftToRight
  655. ProfileLayoutEnforcement.applyForcedLTR(to: educationRowsStack)
  656. let outer = NSStackView(views: [headerRow, educationRowsStack])
  657. outer.orientation = .vertical
  658. outer.spacing = 16
  659. outer.alignment = .width
  660. outer.translatesAutoresizingMaskIntoConstraints = false
  661. outer.userInterfaceLayoutDirection = .leftToRight
  662. ProfileLayoutEnforcement.applyForcedLTR(to: outer)
  663. return outer
  664. }
  665. private func appendWorkExperienceEntry() {
  666. let entry = WorkExperienceEntryView()
  667. entry.translatesAutoresizingMaskIntoConstraints = false
  668. if let compact = lastCompactLayout {
  669. entry.applyCompactLayout(compact)
  670. }
  671. entry.onDelete = { [weak self, weak entry] in
  672. guard let self, let entry else { return }
  673. self.removeWorkExperienceEntry(entry)
  674. }
  675. workExperienceEntries.append(entry)
  676. workExperienceRowsStack.addArrangedSubview(entry)
  677. renumberWorkExperienceEntries()
  678. refreshWorkExperienceDeleteButtons()
  679. }
  680. private func removeWorkExperienceEntry(_ entry: WorkExperienceEntryView) {
  681. guard workExperienceEntries.count > 1 else { return }
  682. workExperienceEntries.removeAll { $0 === entry }
  683. workExperienceRowsStack.removeArrangedSubview(entry)
  684. entry.removeFromSuperview()
  685. renumberWorkExperienceEntries()
  686. refreshWorkExperienceDeleteButtons()
  687. }
  688. private func renumberWorkExperienceEntries() {
  689. for (i, entry) in workExperienceEntries.enumerated() {
  690. entry.setExperienceIndex(i + 1)
  691. }
  692. }
  693. private func refreshWorkExperienceDeleteButtons() {
  694. let hide = workExperienceEntries.count <= 1
  695. for entry in workExperienceEntries {
  696. entry.setDeleteHidden(hide)
  697. }
  698. }
  699. private func appendEducationEntry() {
  700. let entry = EducationEntryView()
  701. entry.translatesAutoresizingMaskIntoConstraints = false
  702. if let compact = lastCompactLayout {
  703. entry.applyCompactLayout(compact)
  704. }
  705. entry.onDelete = { [weak self, weak entry] in
  706. guard let self, let entry else { return }
  707. self.removeEducationEntry(entry)
  708. }
  709. educationEntries.append(entry)
  710. educationRowsStack.addArrangedSubview(entry)
  711. renumberEducationEntries()
  712. refreshEducationDeleteButtons()
  713. }
  714. private func removeEducationEntry(_ entry: EducationEntryView) {
  715. guard educationEntries.count > 1 else { return }
  716. educationEntries.removeAll { $0 === entry }
  717. educationRowsStack.removeArrangedSubview(entry)
  718. entry.removeFromSuperview()
  719. renumberEducationEntries()
  720. refreshEducationDeleteButtons()
  721. }
  722. private func renumberEducationEntries() {
  723. for (i, entry) in educationEntries.enumerated() {
  724. entry.setEducationIndex(i + 1)
  725. }
  726. }
  727. private func refreshEducationDeleteButtons() {
  728. let hide = educationEntries.count <= 1
  729. for entry in educationEntries {
  730. entry.setDeleteHidden(hide)
  731. }
  732. }
  733. @objc private func didTapAddWorkExperience() {
  734. appendWorkExperienceEntry()
  735. }
  736. @objc private func didTapAddEducation() {
  737. appendEducationEntry()
  738. }
  739. private func saveButtonHost() -> NSView {
  740. saveButton.translatesAutoresizingMaskIntoConstraints = false
  741. let host = NSView()
  742. host.translatesAutoresizingMaskIntoConstraints = false
  743. host.userInterfaceLayoutDirection = .leftToRight
  744. ProfileLayoutEnforcement.applyForcedLTR(to: host)
  745. host.addSubview(saveButton)
  746. NSLayoutConstraint.activate([
  747. saveButton.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  748. saveButton.topAnchor.constraint(equalTo: host.topAnchor),
  749. saveButton.bottomAnchor.constraint(equalTo: host.bottomAnchor),
  750. saveButton.heightAnchor.constraint(equalToConstant: 48),
  751. saveButton.trailingAnchor.constraint(lessThanOrEqualTo: host.trailingAnchor)
  752. ])
  753. return host
  754. }
  755. @objc private func didTapSave() {
  756. // UI shell only; wire persistence when profiles are stored.
  757. }
  758. }
  759. // MARK: - Work experience & education rows
  760. private final class WorkExperienceEntryView: NSView {
  761. var onDelete: (() -> Void)?
  762. private let subtitleLabel = NSTextField(labelWithString: "Experience 1")
  763. private let deleteButton = NSButton()
  764. private let jobTitleField = NSTextField()
  765. private let companyField = NSTextField()
  766. private let durationField = NSTextField()
  767. private let descriptionField = NSTextField()
  768. private var jobCompanyRow: ProfileDualFieldRow!
  769. override init(frame frameRect: NSRect) {
  770. super.init(frame: frameRect)
  771. configure()
  772. }
  773. required init?(coder: NSCoder) {
  774. fatalError("init(coder:) has not been implemented")
  775. }
  776. func setExperienceIndex(_ index: Int) {
  777. subtitleLabel.stringValue = "Experience \(index)"
  778. }
  779. func setDeleteHidden(_ hidden: Bool) {
  780. deleteButton.isHidden = hidden
  781. }
  782. func applyCompactLayout(_ compact: Bool) {
  783. jobCompanyRow.setCompact(compact)
  784. }
  785. private func configure() {
  786. userInterfaceLayoutDirection = .leftToRight
  787. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  788. wantsLayer = true
  789. layer?.cornerRadius = 14
  790. layer?.borderWidth = 1
  791. layer?.borderColor = ProfilePagePalette.border.cgColor
  792. layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  793. layer?.masksToBounds = false
  794. layer?.shadowColor = NSColor.black.cgColor
  795. layer?.shadowOpacity = 0.05
  796. layer?.shadowRadius = 12
  797. layer?.shadowOffset = CGSize(width: 0, height: 6)
  798. if #available(macOS 11.0, *) {
  799. layer?.cornerCurve = .continuous
  800. }
  801. subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
  802. subtitleLabel.textColor = ProfilePagePalette.secondaryText
  803. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  804. ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
  805. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  806. deleteButton.isBordered = false
  807. deleteButton.bezelStyle = .regularSquare
  808. deleteButton.focusRingType = .none
  809. deleteButton.contentTintColor = ProfilePagePalette.destructive
  810. deleteButton.target = self
  811. deleteButton.action = #selector(didTapDelete)
  812. if #available(macOS 11.0, *) {
  813. deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove experience")
  814. deleteButton.imagePosition = .imageOnly
  815. } else {
  816. deleteButton.title = "Remove"
  817. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  818. }
  819. let headerSpacer = NSView()
  820. headerSpacer.translatesAutoresizingMaskIntoConstraints = false
  821. headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  822. let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
  823. headerRow.orientation = .horizontal
  824. headerRow.alignment = .centerY
  825. headerRow.distribution = .fill
  826. headerRow.spacing = 8
  827. headerRow.userInterfaceLayoutDirection = .leftToRight
  828. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  829. headerRow.translatesAutoresizingMaskIntoConstraints = false
  830. let jobGroup = Self.labeledFieldStack(title: "Job Title *", field: jobTitleField, placeholder: "e.g., Software Engineer")
  831. let companyGroup = Self.labeledFieldStack(title: "Company Name *", field: companyField, placeholder: "e.g., Google")
  832. jobCompanyRow = ProfileDualFieldRow(left: jobGroup, right: companyGroup, spacing: 12)
  833. let durationGroup = Self.labeledFieldStack(title: "Duration *", field: durationField, placeholder: "e.g., Jan 2020 - Present")
  834. let descriptionGroup = Self.multilineLabeledStack(
  835. title: "Description",
  836. field: descriptionField,
  837. placeholder: "Describe your responsibilities and achievements...",
  838. minHeight: 120
  839. )
  840. let inner = NSStackView(views: [headerRow, jobCompanyRow, durationGroup, descriptionGroup])
  841. inner.orientation = .vertical
  842. inner.spacing = 16
  843. inner.alignment = .width
  844. inner.translatesAutoresizingMaskIntoConstraints = false
  845. inner.edgeInsets = NSEdgeInsets(top: 16, left: 18, bottom: 16, right: 18)
  846. inner.userInterfaceLayoutDirection = .leftToRight
  847. ProfileLayoutEnforcement.applyForcedLTR(to: inner)
  848. addSubview(inner)
  849. NSLayoutConstraint.activate([
  850. inner.leftAnchor.constraint(equalTo: leftAnchor),
  851. inner.rightAnchor.constraint(equalTo: rightAnchor),
  852. inner.topAnchor.constraint(equalTo: topAnchor),
  853. inner.bottomAnchor.constraint(equalTo: bottomAnchor)
  854. ])
  855. }
  856. override func layout() {
  857. super.layout()
  858. for field in [jobTitleField, companyField, durationField, descriptionField] {
  859. if let wrap = field.superview, wrap.bounds.width > 2 {
  860. let w = max(1, wrap.bounds.width - 24)
  861. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  862. field.preferredMaxLayoutWidth = w
  863. }
  864. }
  865. }
  866. guard let layer = layer, layer.shadowOpacity > 0 else { return }
  867. let r = layer.cornerRadius
  868. layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  869. }
  870. @objc private func didTapDelete() {
  871. onDelete?()
  872. }
  873. fileprivate static func labeledFieldStack(title: String, field: NSTextField, placeholder: String) -> NSView {
  874. let label = NSTextField(labelWithString: title)
  875. label.font = .systemFont(ofSize: 12, weight: .medium)
  876. label.textColor = ProfilePagePalette.secondaryText
  877. label.translatesAutoresizingMaskIntoConstraints = false
  878. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  879. styleSingleLineField(field, placeholder: placeholder)
  880. let wrap = roundedChrome(around: field, minHeight: 40)
  881. let stack = NSStackView(views: [label, wrap])
  882. stack.orientation = .vertical
  883. stack.spacing = 8
  884. stack.alignment = .width
  885. stack.translatesAutoresizingMaskIntoConstraints = false
  886. stack.userInterfaceLayoutDirection = .leftToRight
  887. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  888. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  889. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  890. wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  891. NSLayoutConstraint.activate([
  892. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor),
  893. label.leftAnchor.constraint(equalTo: stack.leftAnchor)
  894. ])
  895. return stack
  896. }
  897. private static func multilineLabeledStack(title: String, field: NSTextField, placeholder: String, minHeight: CGFloat) -> NSView {
  898. let label = NSTextField(labelWithString: title)
  899. label.font = .systemFont(ofSize: 12, weight: .medium)
  900. label.textColor = ProfilePagePalette.secondaryText
  901. label.translatesAutoresizingMaskIntoConstraints = false
  902. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  903. field.translatesAutoresizingMaskIntoConstraints = false
  904. field.isEditable = true
  905. field.isSelectable = true
  906. field.isBordered = false
  907. field.drawsBackground = false
  908. field.focusRingType = .none
  909. field.font = .systemFont(ofSize: 14, weight: .regular)
  910. field.textColor = ProfilePagePalette.primaryText
  911. field.maximumNumberOfLines = 0
  912. field.cell?.wraps = true
  913. field.cell?.isScrollable = false
  914. field.cell?.usesSingleLineMode = false
  915. field.placeholderAttributedString = NSAttributedString(
  916. string: placeholder,
  917. attributes: [
  918. .foregroundColor: ProfilePagePalette.secondaryText,
  919. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  920. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  921. ]
  922. )
  923. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  924. let wrap = NSView()
  925. wrap.translatesAutoresizingMaskIntoConstraints = false
  926. wrap.wantsLayer = true
  927. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  928. wrap.layer?.cornerRadius = 10
  929. wrap.layer?.borderWidth = 1
  930. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  931. if #available(macOS 11.0, *) {
  932. wrap.layer?.cornerCurve = .continuous
  933. }
  934. wrap.addSubview(field)
  935. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  936. NSLayoutConstraint.activate([
  937. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  938. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  939. field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  940. field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  941. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  942. ])
  943. let stack = NSStackView(views: [label, wrap])
  944. stack.orientation = .vertical
  945. stack.spacing = 8
  946. stack.alignment = .width
  947. stack.translatesAutoresizingMaskIntoConstraints = false
  948. stack.userInterfaceLayoutDirection = .leftToRight
  949. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  950. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  951. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  952. NSLayoutConstraint.activate([
  953. label.leftAnchor.constraint(equalTo: stack.leftAnchor)
  954. ])
  955. return stack
  956. }
  957. private static func styleSingleLineField(_ field: NSTextField, placeholder: String) {
  958. field.translatesAutoresizingMaskIntoConstraints = false
  959. field.isBordered = false
  960. field.drawsBackground = false
  961. field.focusRingType = .none
  962. field.font = .systemFont(ofSize: 14, weight: .regular)
  963. field.textColor = ProfilePagePalette.primaryText
  964. field.placeholderAttributedString = NSAttributedString(
  965. string: placeholder,
  966. attributes: [
  967. .foregroundColor: ProfilePagePalette.secondaryText,
  968. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  969. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  970. ]
  971. )
  972. field.cell?.usesSingleLineMode = true
  973. field.cell?.wraps = false
  974. field.cell?.isScrollable = true
  975. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  976. }
  977. private static func roundedChrome(around field: NSTextField, minHeight: CGFloat) -> NSView {
  978. let wrap = NSView()
  979. wrap.translatesAutoresizingMaskIntoConstraints = false
  980. wrap.wantsLayer = true
  981. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  982. wrap.layer?.cornerRadius = 10
  983. wrap.layer?.borderWidth = 1
  984. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  985. if #available(macOS 11.0, *) {
  986. wrap.layer?.cornerCurve = .continuous
  987. }
  988. wrap.addSubview(field)
  989. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  990. NSLayoutConstraint.activate([
  991. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  992. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  993. field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
  994. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  995. ])
  996. return wrap
  997. }
  998. }
  999. private final class EducationEntryView: NSView {
  1000. var onDelete: (() -> Void)?
  1001. private let subtitleLabel = NSTextField(labelWithString: "Education 1")
  1002. private let deleteButton = NSButton()
  1003. private let degreeField = NSTextField()
  1004. private let institutionField = NSTextField()
  1005. private let yearField = NSTextField()
  1006. private var degreeInstitutionRow: ProfileDualFieldRow!
  1007. override init(frame frameRect: NSRect) {
  1008. super.init(frame: frameRect)
  1009. configure()
  1010. }
  1011. required init?(coder: NSCoder) {
  1012. fatalError("init(coder:) has not been implemented")
  1013. }
  1014. func setEducationIndex(_ index: Int) {
  1015. subtitleLabel.stringValue = "Education \(index)"
  1016. }
  1017. func setDeleteHidden(_ hidden: Bool) {
  1018. deleteButton.isHidden = hidden
  1019. }
  1020. func applyCompactLayout(_ compact: Bool) {
  1021. degreeInstitutionRow.setCompact(compact)
  1022. }
  1023. private func configure() {
  1024. userInterfaceLayoutDirection = .leftToRight
  1025. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  1026. wantsLayer = true
  1027. layer?.cornerRadius = 14
  1028. layer?.borderWidth = 1
  1029. layer?.borderColor = ProfilePagePalette.border.cgColor
  1030. layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  1031. layer?.masksToBounds = false
  1032. layer?.shadowColor = NSColor.black.cgColor
  1033. layer?.shadowOpacity = 0.04
  1034. layer?.shadowRadius = 10
  1035. layer?.shadowOffset = CGSize(width: 0, height: 5)
  1036. if #available(macOS 11.0, *) {
  1037. layer?.cornerCurve = .continuous
  1038. }
  1039. subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
  1040. subtitleLabel.textColor = ProfilePagePalette.secondaryText
  1041. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  1042. ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
  1043. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  1044. deleteButton.isBordered = false
  1045. deleteButton.bezelStyle = .regularSquare
  1046. deleteButton.focusRingType = .none
  1047. deleteButton.contentTintColor = ProfilePagePalette.destructive
  1048. deleteButton.target = self
  1049. deleteButton.action = #selector(didTapDelete)
  1050. if #available(macOS 11.0, *) {
  1051. deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove education")
  1052. deleteButton.imagePosition = .imageOnly
  1053. } else {
  1054. deleteButton.title = "Remove"
  1055. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  1056. }
  1057. let headerSpacer = NSView()
  1058. headerSpacer.translatesAutoresizingMaskIntoConstraints = false
  1059. headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1060. let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
  1061. headerRow.orientation = .horizontal
  1062. headerRow.alignment = .centerY
  1063. headerRow.distribution = .fill
  1064. headerRow.spacing = 8
  1065. headerRow.userInterfaceLayoutDirection = .leftToRight
  1066. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  1067. headerRow.translatesAutoresizingMaskIntoConstraints = false
  1068. let degreeGroup = WorkExperienceEntryView.labeledFieldStack(
  1069. title: "Degree / program *",
  1070. field: degreeField,
  1071. placeholder: "e.g., BSc Computer Science"
  1072. )
  1073. let institutionGroup = WorkExperienceEntryView.labeledFieldStack(
  1074. title: "Institution *",
  1075. field: institutionField,
  1076. placeholder: "e.g., MIT"
  1077. )
  1078. degreeInstitutionRow = ProfileDualFieldRow(left: degreeGroup, right: institutionGroup, spacing: 12)
  1079. let yearGroup = WorkExperienceEntryView.labeledFieldStack(
  1080. title: "Year *",
  1081. field: yearField,
  1082. placeholder: "e.g., 2020"
  1083. )
  1084. let inner = NSStackView(views: [headerRow, degreeInstitutionRow, yearGroup])
  1085. inner.orientation = .vertical
  1086. inner.spacing = 14
  1087. inner.alignment = .width
  1088. inner.translatesAutoresizingMaskIntoConstraints = false
  1089. inner.edgeInsets = NSEdgeInsets(top: 14, left: 18, bottom: 14, right: 18)
  1090. inner.userInterfaceLayoutDirection = .leftToRight
  1091. ProfileLayoutEnforcement.applyForcedLTR(to: inner)
  1092. addSubview(inner)
  1093. NSLayoutConstraint.activate([
  1094. inner.leftAnchor.constraint(equalTo: leftAnchor),
  1095. inner.rightAnchor.constraint(equalTo: rightAnchor),
  1096. inner.topAnchor.constraint(equalTo: topAnchor),
  1097. inner.bottomAnchor.constraint(equalTo: bottomAnchor)
  1098. ])
  1099. }
  1100. override func layout() {
  1101. super.layout()
  1102. for field in [degreeField, institutionField, yearField] {
  1103. if let wrap = field.superview, wrap.bounds.width > 2 {
  1104. let w = max(1, wrap.bounds.width - 24)
  1105. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  1106. field.preferredMaxLayoutWidth = w
  1107. }
  1108. }
  1109. }
  1110. guard let layer = layer, layer.shadowOpacity > 0 else { return }
  1111. let r = layer.cornerRadius
  1112. layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  1113. }
  1114. @objc private func didTapDelete() {
  1115. onDelete?()
  1116. }
  1117. }
  1118. // MARK: - Primary CTA
  1119. private final class ProfilePrimaryButton: NSButton {
  1120. private var trackingArea: NSTrackingArea?
  1121. private var didPushCursor = false
  1122. override init(frame frameRect: NSRect) {
  1123. super.init(frame: frameRect)
  1124. commonInit()
  1125. }
  1126. required init?(coder: NSCoder) {
  1127. super.init(coder: coder)
  1128. commonInit()
  1129. }
  1130. convenience init(title: String, target: AnyObject?, action: Selector?) {
  1131. self.init(frame: .zero)
  1132. self.title = title
  1133. self.target = target
  1134. self.action = action
  1135. }
  1136. private func commonInit() {
  1137. bezelStyle = .rounded
  1138. isBordered = false
  1139. font = .systemFont(ofSize: 15, weight: .semibold)
  1140. contentTintColor = .white
  1141. wantsLayer = true
  1142. layer?.cornerRadius = 12
  1143. if #available(macOS 11.0, *) {
  1144. layer?.cornerCurve = .continuous
  1145. }
  1146. layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
  1147. }
  1148. override func updateTrackingAreas() {
  1149. super.updateTrackingAreas()
  1150. if let trackingArea { removeTrackingArea(trackingArea) }
  1151. let area = NSTrackingArea(
  1152. rect: bounds,
  1153. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  1154. owner: self,
  1155. userInfo: nil
  1156. )
  1157. addTrackingArea(area)
  1158. trackingArea = area
  1159. }
  1160. override func mouseEntered(with event: NSEvent) {
  1161. super.mouseEntered(with: event)
  1162. layer?.backgroundColor = ProfilePagePalette.brandBlueHover.cgColor
  1163. if !didPushCursor {
  1164. NSCursor.pointingHand.push()
  1165. didPushCursor = true
  1166. }
  1167. }
  1168. override func mouseExited(with event: NSEvent) {
  1169. super.mouseExited(with: event)
  1170. layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
  1171. if didPushCursor {
  1172. NSCursor.pop()
  1173. didPushCursor = false
  1174. }
  1175. }
  1176. override func viewWillMove(toWindow newWindow: NSWindow?) {
  1177. super.viewWillMove(toWindow: newWindow)
  1178. if newWindow == nil, didPushCursor {
  1179. NSCursor.pop()
  1180. didPushCursor = false
  1181. }
  1182. }
  1183. }