Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@ public enum DatePickerCaption {
}
}

/// react-datepicker `yearItemNumber={12}`: pages of 12 years starting at a multiple of 12.
public static let yearsPerPage = 12
/// Web `CAPTION_YEARS_PER_PAGE` / react-datepicker `yearItemNumber`.
public static let yearsPerPage = 20

/// react-datepicker alignment: 1–20, 21–40, … (1981–2000 for 1993).
public static func reactYearPage(containing year: Int) -> [Int] {
let block = (year - 1) / yearsPerPage
let start = block * yearsPerPage + 1
return (start ..< start + yearsPerPage).map { $0 }
}

/// Decade-aligned pages (1980–1999 for 1993); legacy sidebar grids.
public static func yearPage(containing year: Int) -> [Int] {
let start = year - ((year % yearsPerPage) + yearsPerPage) % yearsPerPage
return (start ..< start + yearsPerPage).map { $0 }
Expand All @@ -31,10 +39,21 @@ public enum DatePickerCaption {
]

/// Web caption text: the month and year, or the prompt for the open grid.
public static func yearRangeCaption(pageAnchorYear: Int) -> String {
reactYearRangeCaption(containing: pageAnchorYear)
}

public static func reactYearRangeCaption(containing year: Int) -> String {
let years = reactYearPage(containing: year)
guard let first = years.first, let last = years.last else { return "Select year" }
if first == last { return "Year \(first)" }
return "Years \(first) to \(last)"
}

public static func caption(view: View, monthTitle: String, year: Int) -> String {
switch view {
case .day: monthTitle
case .year: "Select year"
case .year: reactYearRangeCaption(containing: year)
case .month: "Select month, \(year)"
}
}
Expand Down
17 changes: 10 additions & 7 deletions apps/calendar-macos/CompassKitTests/DatePickerCaptionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ final class DatePickerCaptionTests: XCTestCase {
XCTAssertNil(DatePickerCaption.View.day.afterSelect)
}

func testYearPagesStartOnMultiplesOfTwelve() {
XCTAssertEqual(DatePickerCaption.yearPage(containing: 2026).first, 2016)
XCTAssertEqual(DatePickerCaption.yearPage(containing: 2026).last, 2027)
XCTAssertEqual(DatePickerCaption.yearPage(containing: 2016).first, 2016)
XCTAssertEqual(DatePickerCaption.yearPage(containing: 1900).first, 1896)
XCTAssertEqual(DatePickerCaption.yearPage(containing: 2026).count, 12)
func testReactYearPagesMatchWebDatepicker() {
XCTAssertEqual(DatePickerCaption.reactYearPage(containing: 1993).first, 1981)
XCTAssertEqual(DatePickerCaption.reactYearPage(containing: 1993).last, 2000)
XCTAssertEqual(DatePickerCaption.reactYearPage(containing: 2026).first, 2021)
XCTAssertEqual(DatePickerCaption.reactYearPage(containing: 2026).last, 2040)
XCTAssertEqual(DatePickerCaption.reactYearPage(containing: 1993).count, 20)
}

func testCaptionTextMatchesWeb() {
XCTAssertEqual(DatePickerCaption.caption(view: .day, monthTitle: "Jun 2026", year: 2026), "Jun 2026")
XCTAssertEqual(DatePickerCaption.caption(view: .year, monthTitle: "Jun 2026", year: 2026), "Select year")
XCTAssertEqual(
DatePickerCaption.caption(view: .year, monthTitle: "Jun 2026", year: 2026),
"Years 2021 to 2040")
XCTAssertEqual(DatePickerCaption.caption(view: .month, monthTitle: "Jun 2026", year: 2026), "Select month, 2026")
XCTAssertEqual(DatePickerCaption.yearRangeCaption(pageAnchorYear: 1993), "Years 1981 to 2000")
}

func testRangeLimitsYearsMonthsAndDays() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ struct LifeSidebarView: View {
Text("Date of birth").help("Saved to your account when signed in")
TextField("Select date", text: $birthDate)
.accessibilityLabel("Date of birth")
.accessibilityIdentifier(
focusedField == .birthDate
? "compass-life-birth-date-focused"
: "compass-life-birth-date-field")
.focused($focusedField, equals: .birthDate)
.onSubmit { commitBirthDate() }
.help("Enter a date like Jun 10, 2026")
Expand Down Expand Up @@ -109,6 +113,11 @@ struct LifeSidebarView: View {
.padding(20)
}
.onKeyPress(.escape) {
if showsBirthDatePicker {
showsBirthDatePicker = false
focusedField = .birthDate
return .handled
}
focusedField = nil
return .handled
}
Expand Down Expand Up @@ -140,6 +149,7 @@ struct LifeSidebarView: View {

private func openBirthDatePicker() {
pickerMonth = storedBirthDate ?? model.referenceNow
focusedField = .birthDate
showsBirthDatePicker = true
}

Expand All @@ -152,6 +162,7 @@ struct LifeSidebarView: View {
invalidBirthDate = !model.lifeStore.updateBirthDate(LifeDateFormat.stored(from: picked))
refreshInputs()
showsBirthDatePicker = false
focusedField = .birthDate
},
today: model.referenceNow,
daysAreClickable: true,
Expand All @@ -163,6 +174,10 @@ struct LifeSidebarView: View {
.padding(12)
.background(theme.surfacePanelColor)
.accessibilityIdentifier("compass-life-birth-date-picker")
.onExitCommand {
showsBirthDatePicker = false
focusedField = .birthDate
}
}

private func variationButton(_ label: String, symbol: String, direction: Int) -> some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,18 +290,32 @@ public struct SidebarMonthPicker: View {

@ViewBuilder
private var captionTitle: some View {
let label = DatePickerCaption.caption(view: captionView, monthTitle: monthTitle, year: displayedYear)
let pageYear = captionView == .year ? yearPageYear : displayedYear
let label = DatePickerCaption.caption(
view: captionView, monthTitle: monthTitle, year: pageYear)
if captionPicker, captionView == .day {
Button {
yearPageYear = displayedYear
yearPageYear = DatePickerCaption.reactYearPage(containing: displayedYear).first
?? displayedYear
captionView = .year
} label: {
Text(label)
.font(.rubik(14, weight: .medium))
.foregroundStyle(theme.textColor)
.fixedSize()
.padding(.horizontal, 2)
.frame(minWidth: 64, alignment: .leading)
HStack(spacing: 2) {
Text(label)
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .bold))
}
.font(.rubik(14, weight: .medium))
.foregroundStyle(theme.textColor)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background {
RoundedRectangle(cornerRadius: 4)
.fill(theme.textColor.opacity(0.05))
}
.overlay {
RoundedRectangle(cornerRadius: 4)
.strokeBorder(theme.textColor.opacity(0.15), lineWidth: 1)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Choose month and year")
Expand All @@ -311,6 +325,7 @@ public struct SidebarMonthPicker: View {
.foregroundStyle(theme.textColor)
.fixedSize()
.frame(minWidth: 64, alignment: .leading)
.accessibilityLabel(label)
}
}

Expand All @@ -322,7 +337,8 @@ public struct SidebarMonthPicker: View {
case .day:
shiftMonth(direction)
case .year:
yearPageYear += direction * DatePickerCaption.yearsPerPage
let pageStart = DatePickerCaption.reactYearPage(containing: yearPageYear).first ?? yearPageYear
yearPageYear = pageStart + direction * DatePickerCaption.yearsPerPage
case .month:
if let next = calendar.date(byAdding: .year, value: direction, to: displayedMonth) {
displayedMonth = next
Expand All @@ -334,10 +350,10 @@ public struct SidebarMonthPicker: View {
let calendar = EffectiveTimeZone.calendar
let columns = Array(
repeating: GridItem(.flexible(), spacing: 4),
count: captionView == .year ? 3 : 4)
count: 4)
return LazyVGrid(columns: columns, spacing: 4) {
if captionView == .year {
ForEach(DatePickerCaption.yearPage(containing: yearPageYear), id: \.self) { year in
ForEach(DatePickerCaption.reactYearPage(containing: yearPageYear), id: \.self) { year in
captionCell(
label: String(year),
isSelected: year == calendar.component(.year, from: selectedDate),
Expand Down
33 changes: 30 additions & 3 deletions apps/calendar-macos/CompassUITests/LifeBirthDatePickerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ final class LifeBirthDatePickerTests: XCTestCase {
let picker = app.descendants(matching: .any)["compass-life-birth-date-picker"]
XCTAssertTrue(picker.waitForExistence(timeout: 5))
picker.buttons["Choose month and year"].click()
XCTAssertTrue(picker.staticTexts["Select year"].waitForExistence(timeout: 3))
// The year page is a 12-year grid; step back until 1990 is on it.
for _ in 0 ..< 4 where !picker.buttons["1990"].exists {
XCTAssertTrue(picker.buttons["Previous years"].waitForExistence(timeout: 5))
let yearCaption = picker.staticTexts["Years 2021 to 2040"]
XCTAssertTrue(yearCaption.waitForExistence(timeout: 5))
XCTAssertTrue(picker.buttons["2021"].exists)
XCTAssertTrue(picker.buttons["2040"].exists)
// Twenty-year react-aligned pages; step back until 1990 is on it.
for _ in 0 ..< 6 where !picker.buttons["1990"].exists {
picker.buttons["Previous years"].click()
}
XCTAssertTrue(picker.buttons["1990"].waitForExistence(timeout: 3))
picker.buttons["1990"].click()
XCTAssertTrue(picker.staticTexts["Select month, 1990"].waitForExistence(timeout: 3))
picker.buttons["Jun"].click()
Expand All @@ -32,6 +37,28 @@ final class LifeBirthDatePickerTests: XCTestCase {

XCTAssertFalse(picker.waitForExistence(timeout: 2))
XCTAssertEqual(birth.value as? String, "Jun 15, 1990")
XCTAssertTrue(
app.descendants(matching: .any)["compass-life-birth-date-focused"].waitForExistence(timeout: 3))
}

@MainActor
func testBirthDatePickerEscapeRestoresBirthFieldFocus() {
let app = XCUIApplication.compassUITest()
app.launchArguments += ["-COMPASS_FIXTURE", "demo"]
app.launch()
let window = app.windows["Compass"]
XCTAssertTrue(window.waitForExistence(timeout: 15))
window.typeKey("l", modifierFlags: [])
let birth = window.textFields["Date of birth"]
XCTAssertTrue(birth.waitForExistence(timeout: 10))
birth.click()
window.buttons["Open calendar"].click()
let picker = app.descendants(matching: .any)["compass-life-birth-date-picker"]
XCTAssertTrue(picker.waitForExistence(timeout: 5))
birth.typeKey(.escape, modifierFlags: [])
XCTAssertFalse(picker.waitForExistence(timeout: 2))
XCTAssertTrue(
app.descendants(matching: .any)["compass-life-birth-date-focused"].waitForExistence(timeout: 3))
}

@MainActor
Expand Down
Loading
Loading