From a099b8ad7cb5e3f2f262c085e231a0c0e9970fba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Nov 2026 23:17:28 +0000 Subject: [PATCH] fix(life): web and macOS caption date picker parity Align year-page captions and sidebar month picker with shared bounds helpers, and surface Life page-jump chips without overlapping birth-date controls. Co-authored-by: Tyler Dane --- .../CompassKit/Life/DatePickerCaption.swift | 25 +++- .../DatePickerCaptionTests.swift | 17 +-- .../CompassUI/Life/LifeSidebarView.swift | 15 +++ .../Sidebar/SidebarMonthPicker.swift | 38 ++++-- .../LifeBirthDatePickerTests.swift | 33 ++++- .../src/components/DatePicker/DatePicker.tsx | 113 ++++++++++++++---- .../DatePickerCaptionPicker.test.tsx | 97 ++++++++++++++- .../caption-picker-year-page.test.ts | 40 +++++++ .../DatePicker/caption-picker-year-page.ts | 52 ++++++++ .../hint-chips/DigitHintChipOverlay.tsx | 22 +++- .../page-jump/PageJumpHintOverlay.tsx | 7 +- .../page-jump/page-jump.targets.test.ts | 42 +++++++ .../shortcuts/page-jump/page-jump.targets.ts | 46 +++++++ apps/calendar-web/src/views/Life/LifeGrid.tsx | 2 + .../src/views/Life/LifeSidebarContent.tsx | 91 +++++++++----- .../src/views/Life/LifeView.test.tsx | 2 +- 16 files changed, 551 insertions(+), 91 deletions(-) create mode 100644 apps/calendar-web/src/components/DatePicker/caption-picker-year-page.test.ts create mode 100644 apps/calendar-web/src/components/DatePicker/caption-picker-year-page.ts diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/DatePickerCaption.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/DatePickerCaption.swift index 900f23db97..405bc2d3b7 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/DatePickerCaption.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Life/DatePickerCaption.swift @@ -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 } @@ -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)" } } diff --git a/apps/calendar-macos/CompassKitTests/DatePickerCaptionTests.swift b/apps/calendar-macos/CompassKitTests/DatePickerCaptionTests.swift index bd5b776204..bd31a6e8f9 100644 --- a/apps/calendar-macos/CompassKitTests/DatePickerCaptionTests.swift +++ b/apps/calendar-macos/CompassKitTests/DatePickerCaptionTests.swift @@ -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() { diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift index 3f2b6c4eb7..2d6a8360fb 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Life/LifeSidebarView.swift @@ -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") @@ -109,6 +113,11 @@ struct LifeSidebarView: View { .padding(20) } .onKeyPress(.escape) { + if showsBirthDatePicker { + showsBirthDatePicker = false + focusedField = .birthDate + return .handled + } focusedField = nil return .handled } @@ -140,6 +149,7 @@ struct LifeSidebarView: View { private func openBirthDatePicker() { pickerMonth = storedBirthDate ?? model.referenceNow + focusedField = .birthDate showsBirthDatePicker = true } @@ -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, @@ -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 { diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift index deeb76857c..ab9bfd46df 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift @@ -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") @@ -311,6 +325,7 @@ public struct SidebarMonthPicker: View { .foregroundStyle(theme.textColor) .fixedSize() .frame(minWidth: 64, alignment: .leading) + .accessibilityLabel(label) } } @@ -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 @@ -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), diff --git a/apps/calendar-macos/CompassUITests/LifeBirthDatePickerTests.swift b/apps/calendar-macos/CompassUITests/LifeBirthDatePickerTests.swift index 3836fcc23d..d3845694d8 100644 --- a/apps/calendar-macos/CompassUITests/LifeBirthDatePickerTests.swift +++ b/apps/calendar-macos/CompassUITests/LifeBirthDatePickerTests.swift @@ -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() @@ -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 diff --git a/apps/calendar-web/src/components/DatePicker/DatePicker.tsx b/apps/calendar-web/src/components/DatePicker/DatePicker.tsx index e77e7eae7a..b87ba077d7 100644 --- a/apps/calendar-web/src/components/DatePicker/DatePicker.tsx +++ b/apps/calendar-web/src/components/DatePicker/DatePicker.tsx @@ -1,3 +1,4 @@ +import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown"; import { type Modifier, type ModifierArguments, @@ -20,6 +21,13 @@ import { darken, isDark } from "@web/common/styles/color.utils"; import { colors, lightColors } from "@web/common/styles/colors"; import { type CSSVariables } from "@web/common/styles/css.types"; import { theme } from "@web/common/styles/theme"; +import { + CAPTION_YEARS_PER_PAGE, + reactDatepickerYearPageBounds, + shiftYearPageBounds, + type YearPageBounds, + yearRangeCaptionFromBounds, +} from "@web/components/DatePicker/caption-picker-year-page"; import { MonthNavButton } from "@web/components/DatePicker/MonthNavButton"; import { ChevronLeftIcon } from "@web/components/Icons/ChevronLeftIcon"; import { ChevronRightIcon } from "@web/components/Icons/ChevronRightIcon"; @@ -190,10 +198,15 @@ export const DatePicker: React.FC = (datePickerProps) => { setCaptionView(next); }, []); const [captionFocusDate, setCaptionFocusDate] = useState(); + /** Visible year cells; react-datepicker does not move `monthDate` with paging. */ + const [yearPageBounds, setYearPageBounds] = useState( + null, + ); useEffect(() => { if (isOpen) return; commitCaptionView("day"); setCaptionFocusDate(undefined); + setYearPageBounds(null); }, [commitCaptionView, isOpen]); useEffect(() => { @@ -250,7 +263,8 @@ export const DatePicker: React.FC = (datePickerProps) => { const isCaptionYearView = captionPicker && captionView === "year"; const isCaptionMonthView = captionPicker && captionView === "month"; - const isDayGrid = !isCaptionYearView && !isCaptionMonthView; + const isCaptionDayGrid = !isCaptionYearView && !isCaptionMonthView; + const shouldCommitSelection = !captionPicker || captionView === "day"; const monthStepHandlersRef = useRef({ onPrev: () => {}, @@ -262,7 +276,7 @@ export const DatePicker: React.FC = (datePickerProps) => { const sidebarMonthNavShortcuts = Boolean(monthNav); const monthNavShortcutsEnabled = isOpen && - isDayGrid && + isCaptionDayGrid && (gridMonthNavShortcuts || (sidebarMonthNavShortcuts && !isFloatingLayerOpen())); @@ -272,10 +286,16 @@ export const DatePicker: React.FC = (datePickerProps) => { useMonthPickerShortcuts({ enabled: monthNavShortcutsEnabled, - onPrevMonth: () => monthStepHandlersRef.current.onPrev(), - onNextMonth: () => monthStepHandlersRef.current.onNext(), + onPrevMonth: () => { + monthStepHandlersRef.current.onPrev(); + }, + onNextMonth: () => { + monthStepHandlersRef.current.onNext(); + }, onGoToThisMonth: canGoToThisMonthInPicker - ? () => monthStepHandlersRef.current.onGoToThisMonth?.() + ? () => { + monthStepHandlersRef.current.onGoToThisMonth?.(); + } : undefined, }); @@ -295,19 +315,46 @@ export const DatePicker: React.FC = (datePickerProps) => { const handleCalendarClose = () => { commitCaptionView("day"); setCaptionFocusDate(undefined); + setYearPageBounds(null); datePickerProps.onCalendarClose?.(); }; const openCaptionPicker = () => { const selectedDate = datePickerProps.selected; - setCaptionFocusDate( + const anchor = selectedDate instanceof Date && !Number.isNaN(selectedDate.getTime()) ? selectedDate - : new Date(), + : new Date(); + setCaptionFocusDate(anchor); + setYearPageBounds( + reactDatepickerYearPageBounds( + dayjs(anchor).year(), + CAPTION_YEARS_PER_PAGE, + ), ); commitCaptionView("year"); }; + const stepYearPage = ( + headerProps: Parameters< + NonNullable + >[0], + direction: -1 | 1, + ) => { + if (direction < 0) { + headerProps.decreaseYear(); + } else { + headerProps.increaseYear(); + } + setYearPageBounds((current) => { + const anchorYear = dayjs(headerProps.monthDate).year(); + const base = + current ?? + reactDatepickerYearPageBounds(anchorYear, CAPTION_YEARS_PER_PAGE); + return shiftYearPageBounds(base, direction, CAPTION_YEARS_PER_PAGE); + }); + }; + const handleChange: ReactDatePickerProps["onChange"] = (date, event) => { if (captionPicker && captionViewRef.current !== "day") return; datePickerProps.onChange?.(date, event); @@ -342,6 +389,7 @@ export const DatePicker: React.FC = (datePickerProps) => {
= (datePickerProps) => { onCalendarClose={handleCalendarClose} onChange={handleChange} onSelect={handleSelect} - shouldCloseOnSelect={isDayGrid ? props.shouldCloseOnSelect : false} + shouldCloseOnSelect={ + shouldCommitSelection ? props.shouldCloseOnSelect : false + } showFourColumnMonthYearPicker={isCaptionMonthView} showMonthYearPicker={isCaptionMonthView} showYearPicker={isCaptionYearView} - yearItemNumber={12} + yearItemNumber={CAPTION_YEARS_PER_PAGE} // Close the picker when the user clicks away (react-datepicker has no // onCalendarClose for outside-clicks). onCalendarOpen/onCalendarClose/ // onSelect flow straight through {...props}. Kept as a fallback for @@ -405,16 +455,22 @@ export const DatePicker: React.FC = (datePickerProps) => { const selectedMonth = dayjs(monthDate).format("MMM YYYY"); const selectedYear = dayjs(monthDate).format("YYYY"); const currentMonth = dayjs().format("MMM YYYY"); + const visibleYearPage = + yearPageBounds ?? + reactDatepickerYearPageBounds( + dayjs(monthDate).year(), + CAPTION_YEARS_PER_PAGE, + ); const captionLabel = isCaptionYearView - ? "Select year" + ? yearRangeCaptionFromBounds(visibleYearPage) : isCaptionMonthView ? `Select month, ${selectedYear}` : selectedMonth; const useYearNavigation = isCaptionYearView || isCaptionMonthView; const headerNav = useYearNavigation ? { - onPrev: () => headerProps.decreaseYear(), - onNext: () => headerProps.increaseYear(), + onPrev: () => stepYearPage(headerProps, -1), + onNext: () => stepYearPage(headerProps, 1), prevDisabled: headerProps.prevYearButtonDisabled, nextDisabled: headerProps.nextYearButtonDisabled, prevLabel: "Previous years", @@ -438,6 +494,8 @@ export const DatePicker: React.FC = (datePickerProps) => { monthStepHandlersRef.current = { onPrev: headerNav.onPrev, onNext: headerNav.onNext, + // Same rule that decides whether the shortcut is offered at all; + // spelling it twice let the two drift. onGoToThisMonth: canGoToThisMonthInPicker ? jumpToThisMonth : undefined, @@ -449,23 +507,27 @@ export const DatePicker: React.FC = (datePickerProps) => { : headerEndContent; const captionBlock = ( -
- {captionPicker && isDayGrid ? ( +
+ {captionPicker && captionView === "day" ? ( ) : ( {captionLabel} @@ -476,7 +538,10 @@ export const DatePicker: React.FC = (datePickerProps) => { const navBlock = !customHeaderCount && (
= (datePickerProps) => { return (
{captionBlock} - {navBlock} - {!customHeaderCount && resolvedHeaderEndContent ? ( -
- {resolvedHeaderEndContent} -
- ) : null} +
+ {navBlock} + {!customHeaderCount && resolvedHeaderEndContent + ? resolvedHeaderEndContent + : null} +
); }} diff --git a/apps/calendar-web/src/components/DatePicker/DatePickerCaptionPicker.test.tsx b/apps/calendar-web/src/components/DatePicker/DatePickerCaptionPicker.test.tsx index d9a4fbae37..1f007f1f53 100644 --- a/apps/calendar-web/src/components/DatePicker/DatePickerCaptionPicker.test.tsx +++ b/apps/calendar-web/src/components/DatePicker/DatePickerCaptionPicker.test.tsx @@ -121,12 +121,26 @@ describe("DatePicker captionPicker", () => { />, ); - await user.click( - screen.getByRole("button", { name: "Choose month and year" }), - ); + const captionButton = screen.getByRole("button", { + name: "Choose month and year", + }); + expect(captionButton.className).toMatch(/border/); - expect(screen.getByText("Select year")).toBeInTheDocument(); - expect(screen.getByText("2000")).toBeInTheDocument(); + await user.click(captionButton); + + const visiblePickerYears = () => + [ + ...document.querySelectorAll( + ".react-datepicker__year-text", + ), + ] + .map((el) => Number.parseInt(el.textContent ?? "", 10)) + .filter((y) => Number.isFinite(y)); + + expect(screen.getByText("Years 1981 to 2000")).toBeInTheDocument(); + expect(visiblePickerYears()).toEqual([ + ...Array.from({ length: 20 }, (_, index) => 1981 + index), + ]); await user.click(screen.getByText("1995")); @@ -143,4 +157,77 @@ describe("DatePicker captionPicker", () => { fireEvent.click(screen.getByText("15")); expect(onSelect).toHaveBeenCalled(); }); + + it("shows the same range caption as painted year cells for a distant birth year", async () => { + const user = userEvent.setup(); + render( + {}} + onChange={() => {}} + open + selected={new Date(1993, 8, 14)} + view="grid" + withTodayButton={false} + />, + { wrapper }, + ); + + const visiblePickerYears = () => + [ + ...document.querySelectorAll( + ".react-datepicker__year-text", + ), + ] + .map((el) => Number.parseInt(el.textContent ?? "", 10)) + .filter((y) => Number.isFinite(y)); + + await user.click( + screen.getByRole("button", { name: "Choose month and year" }), + ); + const years = visiblePickerYears(); + expect(years[0]).toBe(1981); + expect(years.at(-1)).toBe(2000); + expect(screen.getByText("Years 1981 to 2000")).toBeInTheDocument(); + }); + + it("updates the year-range caption when paging backward", async () => { + const user = userEvent.setup(); + render( + {}} + onChange={() => {}} + open + selected={new Date(2000, 0, 15)} + view="grid" + withTodayButton={false} + />, + { wrapper }, + ); + + await user.click( + screen.getByRole("button", { name: "Choose month and year" }), + ); + expect(screen.getByText("Years 1981 to 2000")).toBeInTheDocument(); + const visiblePickerYears = () => + [ + ...document.querySelectorAll( + ".react-datepicker__year-text", + ), + ] + .map((el) => Number.parseInt(el.textContent ?? "", 10)) + .filter((y) => Number.isFinite(y)); + + await user.click(screen.getByRole("button", { name: "Previous years" })); + expect(visiblePickerYears()[0]).toBe(1961); + expect(visiblePickerYears().at(-1)).toBe(1980); + expect(screen.getByText("Years 1961 to 1980")).toBeInTheDocument(); + }); }); diff --git a/apps/calendar-web/src/components/DatePicker/caption-picker-year-page.test.ts b/apps/calendar-web/src/components/DatePicker/caption-picker-year-page.test.ts new file mode 100644 index 0000000000..46fda33455 --- /dev/null +++ b/apps/calendar-web/src/components/DatePicker/caption-picker-year-page.test.ts @@ -0,0 +1,40 @@ +import { + macYearRangeCaption, + reactDatepickerYearPageBounds, + shiftYearPageBounds, + yearRangeCaptionFromBounds, +} from "@web/components/DatePicker/caption-picker-year-page"; +import { describe, expect, it } from "bun:test"; + +describe("caption-picker-year-page", () => { + it("matches react-datepicker 1-based twenty-year pages", () => { + expect(reactDatepickerYearPageBounds(1993)).toEqual({ + start: 1981, + end: 2000, + }); + expect(reactDatepickerYearPageBounds(2000)).toEqual({ + start: 1981, + end: 2000, + }); + expect(reactDatepickerYearPageBounds(2026)).toEqual({ + start: 2021, + end: 2040, + }); + }); + + it("labels the visible page bounds", () => { + expect( + yearRangeCaptionFromBounds(reactDatepickerYearPageBounds(1993)), + ).toBe("Years 1981 to 2000"); + }); + + it("steps pages by one twenty-year block", () => { + const page = reactDatepickerYearPageBounds(1993); + expect(shiftYearPageBounds(page, -1)).toEqual({ start: 1961, end: 1980 }); + expect(shiftYearPageBounds(page, 1)).toEqual({ start: 2001, end: 2020 }); + }); + + it("keeps mac decade alignment separate", () => { + expect(macYearRangeCaption(1993)).toBe("Years 1980 to 1999"); + }); +}); diff --git a/apps/calendar-web/src/components/DatePicker/caption-picker-year-page.ts b/apps/calendar-web/src/components/DatePicker/caption-picker-year-page.ts new file mode 100644 index 0000000000..dabf4a9719 --- /dev/null +++ b/apps/calendar-web/src/components/DatePicker/caption-picker-year-page.ts @@ -0,0 +1,52 @@ +/** Keep in sync with `DatePickerCaption.reactYearsPerPage` on macOS. */ +export const CAPTION_YEARS_PER_PAGE = 20; + +export type YearPageBounds = { readonly start: number; readonly end: number }; + +/** + * react-datepicker year picker (`yearItemNumber`): 20-year pages + * 1–20, 21–40, …, 1981–2000 (same grid the library paints). + */ +export const reactDatepickerYearPageBounds = ( + year: number, + pageSize = CAPTION_YEARS_PER_PAGE, +): YearPageBounds => { + const block = Math.floor((year - 1) / pageSize); + const start = block * pageSize + 1; + return { start, end: start + pageSize - 1 }; +}; + +export const shiftYearPageBounds = ( + bounds: YearPageBounds, + direction: -1 | 1, + pageSize = CAPTION_YEARS_PER_PAGE, +): YearPageBounds => { + const block = Math.floor((bounds.start - 1) / pageSize); + const start = (block + direction) * pageSize + 1; + return { start, end: start + pageSize - 1 }; +}; + +export const yearRangeCaptionFromBounds = (bounds: YearPageBounds): string => { + const { start, end } = bounds; + return start === end ? `Year ${start}` : `Years ${start} to ${end}`; +}; + +/** macOS decade-aligned pages (0, 20, 40, …) for non-react grids. */ +export const macYearPageContaining = ( + year: number, + pageSize = CAPTION_YEARS_PER_PAGE, +): readonly number[] => { + const start = year - (((year % pageSize) + pageSize) % pageSize); + return Array.from({ length: pageSize }, (_, index) => start + index); +}; + +export const macYearRangeCaption = ( + anchorYear: number, + pageSize = CAPTION_YEARS_PER_PAGE, +): string => { + const page = macYearPageContaining(anchorYear, pageSize); + const first = page[0]; + const last = page[page.length - 1]; + if (first === undefined || last === undefined) return "Select year"; + return first === last ? `Year ${first}` : `Years ${first} to ${last}`; +}; diff --git a/apps/calendar-web/src/shortcuts/hint-chips/DigitHintChipOverlay.tsx b/apps/calendar-web/src/shortcuts/hint-chips/DigitHintChipOverlay.tsx index 70d045a5a0..5c0374643f 100644 --- a/apps/calendar-web/src/shortcuts/hint-chips/DigitHintChipOverlay.tsx +++ b/apps/calendar-web/src/shortcuts/hint-chips/DigitHintChipOverlay.tsx @@ -1,6 +1,10 @@ import { createPortal } from "react-dom"; import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint"; +import { + PAGE_JUMP_CHIP_PLACEMENT_ATTRIBUTE, + type PageJumpChipPlacement, +} from "@web/shortcuts/page-jump/page-jump.targets"; import { getVisibleHintRect } from "@web/shortcuts/shift-hint/shift-hint-visible-rect"; import { useHintLayoutRefresh } from "@web/shortcuts/useHintLayoutRefresh"; @@ -15,6 +19,18 @@ export type DigitHintChip = { /** Widest a single-character keycap gets, used to keep chips on screen. */ const CHIP_WIDTH_PX = 22; +/** Gap between an `outside-trailing` chip and the marker's right edge. */ +const CHIP_OUTSIDE_GUTTER_PX = 4; + +const chipLeftPx = ( + visibleRect: { readonly left: number; readonly right: number }, + placement: PageJumpChipPlacement | null, +): number => { + if (placement === "outside-trailing") { + return visibleRect.right + CHIP_OUTSIDE_GUTTER_PX; + } + return Math.max(4, visibleRect.right - CHIP_WIDTH_PX); +}; /** * Numbered keycap chips anchored to each target while Mod is held, shared by @@ -72,13 +88,17 @@ export function DigitHintChipOverlay({ return null; } + const placement = chip.anchor.getAttribute( + PAGE_JUMP_CHIP_PLACEMENT_ATTRIBUTE, + ) as PageJumpChipPlacement | null; + return ( {chip.digit} diff --git a/apps/calendar-web/src/shortcuts/page-jump/PageJumpHintOverlay.tsx b/apps/calendar-web/src/shortcuts/page-jump/PageJumpHintOverlay.tsx index 75c359f012..94bf4a3e08 100644 --- a/apps/calendar-web/src/shortcuts/page-jump/PageJumpHintOverlay.tsx +++ b/apps/calendar-web/src/shortcuts/page-jump/PageJumpHintOverlay.tsx @@ -4,7 +4,7 @@ import { } from "@web/shortcuts/hint-chips/DigitHintChipOverlay"; import { CALENDAR_PAGE_JUMP_TARGETS, - getPageJumpAnchor, + getPageJumpChipAnchor, getPageJumpFocusElement, type PageJumpTargets, } from "@web/shortcuts/page-jump/page-jump.targets"; @@ -25,8 +25,9 @@ export function PageJumpHintOverlay({ // card has nothing focusable, so their digits would do nothing there. const resolveChips = (): DigitHintChip[] => targets.flatMap((target) => { - const anchor = getPageJumpAnchor(target.id); - if (!anchor || !getPageJumpFocusElement(target.id)) return []; + if (!getPageJumpFocusElement(target.id)) return []; + const anchor = getPageJumpChipAnchor(target.id); + if (!anchor) return []; return [ { key: target.id, digit: target.digit, label: target.label, anchor }, ]; diff --git a/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.test.ts b/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.test.ts index 0d7b8b779e..69f291eeb5 100644 --- a/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.test.ts +++ b/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.test.ts @@ -9,9 +9,11 @@ import { dayColumnJumpDigits, dayColumnJumpId, focusPageJumpTarget, + getPageJumpChipAnchor, getPageJumpFocusElement, LIFE_PAGE_JUMP_TARGETS, PAGE_JUMP_ATTRIBUTE, + PAGE_JUMP_CHIP_ATTRIBUTE, type PageJumpTargetId, } from "@web/shortcuts/page-jump/page-jump.targets"; import { afterEach, describe, expect, it } from "bun:test"; @@ -235,6 +237,46 @@ describe("buildDayPageJumpTargets", () => { }); }); +describe("getPageJumpChipAnchor", () => { + it("prefers an explicit chip marker over the section box", () => { + const anchor = addAnchor("life-details"); + const chipHost = document.createElement("div"); + chipHost.setAttribute(PAGE_JUMP_CHIP_ATTRIBUTE, "life-details"); + const birthInput = document.createElement("input"); + chipHost.append(birthInput); + anchor.append(chipHost); + + expect(getPageJumpChipAnchor("life-details")).toBe(chipHost); + }); + + it("falls back to the focus target when it has layout", () => { + const anchor = addAnchor("calendars"); + const toggle = document.createElement("button"); + toggle.getBoundingClientRect = () => + ({ + width: 24, + height: 24, + top: 0, + left: 0, + bottom: 24, + right: 24, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + anchor.append(toggle); + + expect(getPageJumpChipAnchor("calendars")).toBe(toggle); + }); + + it("falls back to the section when the focus target has no layout", () => { + const anchor = addAnchor("calendars"); + anchor.append(document.createElement("button")); + + expect(getPageJumpChipAnchor("calendars")).toBe(anchor); + }); +}); + describe("getPageJumpFocusElement", () => { it("returns null when the anchor is not mounted", () => { expect(getPageJumpFocusElement("month-picker")).toBeNull(); diff --git a/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.ts b/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.ts index fd3e790bc2..d5ce633a0c 100644 --- a/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.ts +++ b/apps/calendar-web/src/shortcuts/page-jump/page-jump.targets.ts @@ -32,6 +32,14 @@ import { PICK_KEY_LABELS } from "@web/shortcuts/digit-pick.util"; export const PAGE_JUMP_ATTRIBUTE = "data-page-jump"; +/** Optional element inside a jump anchor where hold-Mod chips should paint. */ +export const PAGE_JUMP_CHIP_ATTRIBUTE = "data-page-jump-chip"; +export const PAGE_JUMP_CHIP_PLACEMENT_ATTRIBUTE = + "data-page-jump-chip-placement"; + +/** Where the digit chip sits relative to the chip marker's visible box. */ +export type PageJumpChipPlacement = "inset-trailing" | "outside-trailing"; + export const DAY_COLUMN_JUMP_ID_PREFIX = "day-column:" as const; export const CALENDAR_ACCOUNT_JUMP_ID_PREFIX = "calendar-account:" as const; @@ -182,6 +190,20 @@ export const pageJumpAttrs = ( [PAGE_JUMP_ATTRIBUTE]: id, }); +/** Marks the control users land on so digit chips sit beside it, not the section edge. */ +export const pageJumpChipAttrs = ( + id: PageJumpTargetId, + options?: { placement?: PageJumpChipPlacement }, +): { + [PAGE_JUMP_CHIP_ATTRIBUTE]: PageJumpTargetId; + [PAGE_JUMP_CHIP_PLACEMENT_ATTRIBUTE]?: PageJumpChipPlacement; +} => ({ + [PAGE_JUMP_CHIP_ATTRIBUTE]: id, + ...(options?.placement + ? { [PAGE_JUMP_CHIP_PLACEMENT_ATTRIBUTE]: options.placement } + : {}), +}); + /** * Match by attribute value rather than interpolating `id` into a selector: * account keys can contain CSS-significant characters (`:`, `+`, `.`). @@ -219,6 +241,30 @@ export const getPageJumpFocusElement = ( ); }; +/** + * Where hold-Mod chips anchor: an explicit chip marker inside the jump + * section, else the focus target, else the section (calendar views). + */ +export const getPageJumpChipAnchor = ( + id: PageJumpTargetId, +): HTMLElement | null => { + const anchor = getPageJumpAnchor(id); + if (!anchor) return null; + + const chipMarker = anchor.querySelector( + `[${PAGE_JUMP_CHIP_ATTRIBUTE}="${id}"]`, + ); + if (chipMarker) return chipMarker; + + const focusTarget = getPageJumpFocusElement(id); + if (focusTarget && anchor.contains(focusTarget)) { + const { width, height } = focusTarget.getBoundingClientRect(); + if (width > 0 && height > 0) return focusTarget; + } + + return anchor; +}; + const shouldOpenOnJump = (element: HTMLElement): boolean => element.getAttribute("aria-expanded") === "false"; diff --git a/apps/calendar-web/src/views/Life/LifeGrid.tsx b/apps/calendar-web/src/views/Life/LifeGrid.tsx index 6d88f68dd4..2335421ff6 100644 --- a/apps/calendar-web/src/views/Life/LifeGrid.tsx +++ b/apps/calendar-web/src/views/Life/LifeGrid.tsx @@ -5,6 +5,7 @@ import { TooltipContent, TooltipTrigger, } from "@web/components/Tooltip/Tooltip"; +import { pageJumpChipAttrs } from "@web/shortcuts/page-jump/page-jump.targets"; interface LifeGridProps { showCurrentWeek: boolean; @@ -90,6 +91,7 @@ export function LifeGrid({ aria-label={currentWeekLabel} className="flex min-w-0 appearance-none items-center justify-center border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-accent" ref={currentWeekRef} + {...pageJumpChipAttrs("life-grid")} > {dot} diff --git a/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx b/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx index e468f43f22..47af1b1c56 100644 --- a/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx +++ b/apps/calendar-web/src/views/Life/LifeSidebarContent.tsx @@ -14,7 +14,10 @@ import { ID_LIFE_BIRTH_DATE } from "@web/common/constants/web.constants"; import { ArrowButton } from "@web/components/Button/ArrowButton"; import { NumberInput } from "@web/components/NumberInput/NumberInput"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { pageJumpAttrs } from "@web/shortcuts/page-jump/page-jump.targets"; +import { + pageJumpAttrs, + pageJumpChipAttrs, +} from "@web/shortcuts/page-jump/page-jump.targets"; import { LifeBirthDateField } from "@web/views/Life/LifeBirthDateField"; import { LifeQuote } from "./LifeQuote"; import { type LifePreferences } from "./life-preferences.storage"; @@ -95,26 +98,43 @@ export function LifeSidebarContent({ className="flex flex-col gap-2 text-text-muted" {...pageJumpAttrs("life-variation")} > -
- - onCycleVariation(-1)} - /> - - - onCycleVariation(1)} - /> - -

- {LIFE_VARIATIONS[preferences.variation].label} -

+
+
+
+ + onCycleVariation(-1)} + /> + + + onCycleVariation(1)} + /> + +
+ {/* Portaled Mod chips do not affect layout; reserve space so chip 3 + sits between the arrows and the title without covering the label. */} + +

+ {LIFE_VARIATIONS[preferences.variation].label} +

+
{headerEndContent ? ( -
{headerEndContent}
+
+ {headerEndContent} +
) : null}

{getLifeVariationDescription(preferences.lifespan)}

@@ -139,19 +159,24 @@ export function LifeSidebarContent({ Date of birth - setIsBirthDatePickerOpen(false)} - onCalendarOpen={() => { - flushSync(() => setIsBirthDatePickerOpen(true)); - }} - onCommit={commitBirthDate} - storedBirthDate={preferences.birthDate} - today={today} - /> +
+ setIsBirthDatePickerOpen(false)} + onCalendarOpen={() => { + flushSync(() => setIsBirthDatePickerOpen(true)); + }} + onCommit={commitBirthDate} + storedBirthDate={preferences.birthDate} + today={today} + /> +
diff --git a/apps/calendar-web/src/views/Life/LifeView.test.tsx b/apps/calendar-web/src/views/Life/LifeView.test.tsx index a369fc768f..7fd3b4981c 100644 --- a/apps/calendar-web/src/views/Life/LifeView.test.tsx +++ b/apps/calendar-web/src/views/Life/LifeView.test.tsx @@ -275,7 +275,7 @@ describe("LifeView", () => { ) as HTMLElement | null; expect(calendar).not.toBeNull(); - expect(within(calendar!).getByText("Select year")).toBeInTheDocument(); + expect(within(calendar!).getByText(/Years \d+ to \d+/)).toBeInTheDocument(); expect( calendar!.querySelector(".react-datepicker__year-text"), ).toBeInTheDocument();