Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ public final class NativeCalendarRootModel {
applyFocusPresentation()
}

/// Web `onFocusCalendar` / `u`: seed grid focus on the nearest event when nothing is focused.
public func focusFirstCalendarEvent() {
guard viewStore.view != .life else { return }
guard focusStore.focusedEventId == nil else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ final class NativeCalendarRootModelFocusTests: XCTestCase {
XCTAssertEqual(model.draftStore.gridDraft?.title, "Morning standup")
}

func testFocusEventShortcutSeedsNearestEvent() async throws {
let fixture = try DemoSeedFixture.load()
let environment = try NativeCalendarEnvironment(inMemoryDatabase: true)
let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture)
await model.start()

XCTAssertNil(model.timeGridState.focusedEventId)
model.handleShortcut(.focusEvent)
XCTAssertNotNil(model.timeGridState.focusedEventId)
XCTAssertNotNil(model.gridFocusAccessibilityLabel)
}

func testShowPointerHintForEventCardKeepsFocusedGridEventLabel() async throws {
let fixture = try DemoSeedFixture.load()
let environment = try NativeCalendarEnvironment(inMemoryDatabase: true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Closed-label date format for the sidebar event form. Matches web `formatEventFormDateLabel`.
public enum EventFormDateFormat {
private static let labelFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = EffectiveTimeZone.timeZone
formatter.dateFormat = "MM.dd.yy"
return formatter
}()

public static func formatLabel(for date: Date) -> String {
labelFormatter.string(from: date)
}

/// Parses typed form dates using the same rules as go-to-date / web event form.
public static func parseTypedLabel(_ text: String, now: Date) -> Date? {
UserDateParsing.parseUserDate(text, now: now)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import CoreGraphics

/// Layout tokens mirrored from web `index.css` `@theme` (radius + type scale).
public enum NativeWebLayoutTokens {
public static let radiusDefault: CGFloat = 4
public static let radiusPanel: CGFloat = 12

public static let fontSizeXS: CGFloat = 9
public static let fontSizeS: CGFloat = 11
public static let fontSizeM: CGFloat = 13
public static let fontSizeBody: CGFloat = 14
public static let fontSizeL: CGFloat = 16
public static let fontSizeXL: CGFloat = 18
}
14 changes: 14 additions & 0 deletions apps/calendar-macos/CompassKitTests/EventFormDateFormatTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import CompassKit
import XCTest

final class EventFormDateFormatTests: XCTestCase {
override func setUp() {
super.setUp()
EffectiveTimeZone.identifier = "UTC"
}

func testFormatLabelUsesMonthDayYearDots() throws {
let date = try XCTUnwrap(CompassDateParsing.parseInEffectiveTimeZone("2026-10-17T12:00:00"))
XCTAssertEqual(EventFormDateFormat.formatLabel(for: date), "10.17.26")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import CompassKit
import SwiftUI

/// Web `FormCard`: grouped fields on `surface-overlay` with default radius.
struct EventFormCard<Content: View>: View {
@Environment(\.nativeWebTheme) private var theme
@ViewBuilder var content: () -> Content

var body: some View {
content()
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.surfaceOverlayColor)
.clipShape(RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import CompassKit
import SwiftUI

struct EventFormCompactDateField: View {
@Environment(\.nativeWebTheme) private var theme
@Binding var date: Date
let accessibilityIdentifier: String
@State private var text: String
@State private var showsCalendar = false
@FocusState private var isFocused: Bool

init(date: Binding<Date>, accessibilityIdentifier: String) {
_date = date
self.accessibilityIdentifier = accessibilityIdentifier
_text = State(initialValue: EventFormDateFormat.formatLabel(for: date.wrappedValue))
}

var body: some View {
TextField("Date", text: $text)
.textFieldStyle(.plain)
.font(.rubik(13))
.foregroundStyle(theme.textColor)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(theme.surfacePanelColor)
.clipShape(RoundedRectangle(cornerRadius: 4))
.overlay {
RoundedRectangle(cornerRadius: 4)
.stroke(isFocused ? theme.accentColor : theme.borderColor, lineWidth: 1)
}
.focused($isFocused)
.accessibilityIdentifier(accessibilityIdentifier)
.onSubmit { commitTypedDate() }
.onChange(of: isFocused) { _, focused in
if !focused { commitTypedDate() }
}
.onChange(of: date) { _, newValue in
guard !isFocused else { return }
text = EventFormDateFormat.formatLabel(for: newValue)
}
.onTapGesture(count: 2) {
showsCalendar = true
}
.popover(isPresented: $showsCalendar, arrowEdge: .bottom) {
DatePicker(
"Pick date",
selection: Binding(
get: { date },
set: { newDate in
date = newDate
text = EventFormDateFormat.formatLabel(for: newDate)
}
),
displayedComponents: [.date]
)
.datePickerStyle(.graphical)
.labelsHidden()
.padding(12)
}
}

private func commitTypedDate() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
text = EventFormDateFormat.formatLabel(for: date)
return
}
if let parsed = EventFormDateFormat.parseTypedLabel(trimmed, now: date) {
date = parsed
text = EventFormDateFormat.formatLabel(for: parsed)
return
}
text = EventFormDateFormat.formatLabel(for: date)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,57 +23,75 @@ struct EventFormScheduleSection: View {
.accessibilityIdentifier("compass-event-form-allday")

if draft.schedule.kind == .timed {
DatePicker(
"Start",
selection: Binding(
get: { draft.schedule.start },
set: { updateStart($0) }
),
displayedComponents: [.date, .hourAndMinute]
)
.labelsHidden()
.disabled(scheduleLocked)
.accessibilityIdentifier("compass-event-form-start")
.pageJumpChipAnchor(id: "form-start")
HStack(spacing: 8) {
EventFormCompactDateField(
date: Binding(
get: { draft.schedule.start },
set: { updateStart(mergeCalendarDay(into: draft.schedule.start, day: $0)) }
),
accessibilityIdentifier: "compass-event-form-start-date"
)
.disabled(scheduleLocked)
.pageJumpChipAnchor(id: "form-start")

DatePicker(
"End",
selection: Binding(
get: { draft.schedule.end },
set: { updateEnd($0) }
),
displayedComponents: [.date, .hourAndMinute]
)
.labelsHidden()
.disabled(scheduleLocked)
.accessibilityIdentifier("compass-event-form-end")
.pageJumpChipAnchor(id: "form-end")
DatePicker(
"Start time",
selection: Binding(
get: { draft.schedule.start },
set: { updateStart($0) }
),
displayedComponents: [.hourAndMinute]
)
.labelsHidden()
.disabled(scheduleLocked)
.accessibilityIdentifier("compass-event-form-start")
}

HStack(spacing: 8) {
EventFormCompactDateField(
date: Binding(
get: { draft.schedule.end },
set: { updateEnd(mergeCalendarDay(into: draft.schedule.end, day: $0)) }
),
accessibilityIdentifier: "compass-event-form-end-date"
)
.disabled(scheduleLocked)
.pageJumpChipAnchor(id: "form-end")

DatePicker(
"End time",
selection: Binding(
get: { draft.schedule.end },
set: { updateEnd($0) }
),
displayedComponents: [.hourAndMinute]
)
.labelsHidden()
.disabled(scheduleLocked)
.accessibilityIdentifier("compass-event-form-end")
}
} else {
DatePicker(
"Start day",
selection: Binding(
EventFormCompactDateField(
date: Binding(
get: { draft.schedule.start },
set: { model.updateAllDayDraftStart($0) }
),
displayedComponents: [.date]
accessibilityIdentifier: "compass-event-form-start"
)
.disabled(scheduleLocked)
.accessibilityIdentifier("compass-event-form-start")
.pageJumpChipAnchor(id: "form-start")

DatePicker(
"End day",
selection: Binding(
EventFormCompactDateField(
date: Binding(
get: {
EffectiveTimeZone.calendar.date(byAdding: .day, value: -1, to: draft.schedule.end)
?? draft.schedule.start
},
set: { model.updateAllDayDraftEnd($0) }
),
displayedComponents: [.date]
accessibilityIdentifier: "compass-event-form-end"
)
.disabled(scheduleLocked)
.accessibilityIdentifier("compass-event-form-end")
.pageJumpChipAnchor(id: "form-end")
}

Expand All @@ -87,7 +105,16 @@ struct EventFormScheduleSection: View {
.accessibilityIdentifier("compass-event-form-schedule-locked-note")
}
}
.eventFormSectionChrome(focused: focusField == .start || focusField == .end)
}

private func mergeCalendarDay(into scheduleDate: Date, day: Date) -> Date {
var calendar = EffectiveTimeZone.calendar
let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleDate)
var dayComponents = calendar.dateComponents([.year, .month, .day], from: day)
dayComponents.hour = time.hour
dayComponents.minute = time.minute
dayComponents.second = time.second ?? 0
return calendar.date(from: dayComponents) ?? day
}

private func updateStart(_ date: Date) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CompassKit
import SwiftUI

struct EventFormSectionChrome: ViewModifier {
Expand All @@ -6,14 +7,14 @@ struct EventFormSectionChrome: ViewModifier {

func body(content: Content) -> some View {
content
.font(.rubik(13))
.font(.rubik(NativeWebLayoutTokens.fontSizeM))
.controlSize(.small)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(theme.surfaceOverlayColor)
.clipShape(RoundedRectangle(cornerRadius: 4))
.clipShape(RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault))
.overlay(
RoundedRectangle(cornerRadius: 4)
RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault)
.stroke(focused ? theme.accentColor : .clear, lineWidth: 1)
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CompassData
import CompassKit
import SwiftUI

struct EventFormToolbarView: View {
Expand All @@ -8,28 +9,43 @@ struct EventFormToolbarView: View {

var body: some View {
HStack(spacing: 8) {
toolbarButton(label: "Close", systemImage: "xmark") { model.requestCloseEventForm() }
toolbarButton(label: "Close", systemImage: "xmark", keycaps: ["Esc"]) {
model.requestCloseEventForm()
}
if draft.kind == .edit {
toolbarButton(label: "Duplicate", systemImage: "plus.square.on.square") {
toolbarButton(label: "Duplicate", systemImage: "plus.square.on.square", keycaps: ["Mod", "D"]) {
Task { await model.duplicateFocusedOrFormEvent() }
}
if model.hasBookedMeetingActions {
BookingEventActionsView(model: model)
} else if !model.isEventFormReadOnly(for: draft) {
toolbarButton(label: "Delete", systemImage: "trash") { Task { await model.deleteFormEvent() } }
toolbarButton(label: "Delete", systemImage: "trash", keycaps: ["Delete"]) {
Task { await model.deleteFormEvent() }
}
}
}
Spacer()
}
.accessibilityIdentifier("compass-event-form-actions")
}

private func toolbarButton(label: String, systemImage: String, action: @escaping () -> Void) -> some View {
private func toolbarButton(
label: String,
systemImage: String,
keycaps: [String],
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.textMutedColor)
.frame(width: 28, height: 28)
VStack(spacing: 2) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.textMutedColor)
.frame(width: 28, height: 22)
Text(KeycapGlyphs.display(keycaps))
.font(.rubik(NativeWebLayoutTokens.fontSizeXS, weight: .medium))
.foregroundStyle(theme.textSubtleColor)
}
.frame(minWidth: 36)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
Expand Down
Loading
Loading