diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/EdgeFocusHint.swift b/apps/calendar-macos/CompassData/Sources/CompassData/EdgeFocusHint.swift new file mode 100644 index 000000000..40eee1820 --- /dev/null +++ b/apps/calendar-macos/CompassData/Sources/CompassData/EdgeFocusHint.swift @@ -0,0 +1,45 @@ +import CompassKit +import Foundation + +/// Web `edge-focus-hint.parts.ts`: teach nudge keys while an edge is focused. +enum EdgeFocusHint { + private static let modeAnnouncements: Set = [ + "Editing start time", + "Editing end time", + "Editing whole event", + ] + + static func statusText( + edge: EventEdge?, + isAllDay: Bool, + announcement: String + ) -> String { + let movement = movementPhrase(edge: edge, isAllDay: isAllDay) + let cycle = "Tab cycles target" + if !announcement.isEmpty, !modeAnnouncements.contains(announcement) { + return "\(announcement) · \(movement) · \(cycle)" + } + return "\(movement) · \(cycle)" + } + + private static func movementPhrase(edge: EventEdge?, isAllDay: Bool) -> String { + if edge == nil { + return isAllDay + ? "Shift+ArrowLeft/ArrowRight moves event" + : "Shift+arrows moves event" + } + let noun: String + switch edge { + case .startDate: + noun = isAllDay ? "start date" : "start" + case .endDate: + noun = isAllDay ? "end date" : "end" + case .none: + noun = "event" + } + if isAllDay { + return "Shift+ArrowLeft/ArrowRight adjusts \(noun)" + } + return "Shift+ArrowUp/ArrowDown adjusts \(noun)" + } +} diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+KeyboardFocus.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+KeyboardFocus.swift index 5f9283077..d2136b5b1 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+KeyboardFocus.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+KeyboardFocus.swift @@ -433,13 +433,25 @@ extension NativeCalendarRootModel { private func cycleFocusedEdge(forward: Bool) { guard let eventId = focusStore.focusedEventId else { return } + let isAllDay = + focusLayoutCards.first(where: { $0.eventId == eventId.rawValue })?.isAllDay ?? false focusStore.cycleEdge( for: eventId, - direction: forward ? .forward : .backward + direction: forward ? .forward : .backward, + isAllDay: isAllDay ) applyFocusPresentation() } + public var edgeFocusStatusText: String? { + guard !focusStore.edgeAnnouncement.isEmpty else { return nil } + return EdgeFocusHint.statusText( + edge: focusStore.edge, + isAllDay: focusStore.edgeFocusIsAllDay, + announcement: focusStore.edgeAnnouncement + ) + } + func focusEvent(eventId: String) { clearMonthPickerFocus() let type: ViewInteractionEventType = diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/FocusStore.swift b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/FocusStore.swift index 6110a47ec..e17f85e68 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/FocusStore.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/FocusStore.swift @@ -26,6 +26,7 @@ public final class FocusStore { public private(set) var focusedEventType: ViewInteractionEventType? public private(set) var edge: EventEdge? public private(set) var edgeAnnouncement: String = "" + public private(set) var edgeFocusIsAllDay: Bool = false public private(set) var pageJumpTargets: [PageJumpTarget] = [] public var pageJumpHintsVisible = false @@ -74,7 +75,11 @@ public final class FocusStore { } } - public func cycleEdge(for eventId: EventId, direction: CycleDirection) { + public func cycleEdge( + for eventId: EventId, + direction: CycleDirection, + isAllDay: Bool = false + ) { let cycle: [EventEdge?] = [nil, .startDate, .endDate] let currentEdge: EventEdge? = focusedEventId == eventId ? edge : nil @@ -84,12 +89,19 @@ public final class FocusStore { let next = cycle[nextIndex] focusedEventId = eventId edge = next + edgeFocusIsAllDay = isAllDay edgeAnnouncement = Self.edgeAnnouncement(next) } - public func setEdge(eventId: EventId, edge: EventEdge, announcement: String) { + public func setEdge( + eventId: EventId, + edge: EventEdge, + announcement: String, + isAllDay: Bool = false + ) { focusedEventId = eventId self.edge = edge + edgeFocusIsAllDay = isAllDay edgeAnnouncement = announcement } @@ -98,6 +110,7 @@ public final class FocusStore { focusedEventType = nil edge = nil edgeAnnouncement = "" + edgeFocusIsAllDay = false } public func setPageJumpTargets(_ targets: [PageJumpTarget]) { diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/EdgeFocusHintTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/EdgeFocusHintTests.swift new file mode 100644 index 000000000..04b17fc07 --- /dev/null +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/EdgeFocusHintTests.swift @@ -0,0 +1,87 @@ +import CompassKit +import XCTest +@testable import CompassData + +final class EdgeFocusHintTests: XCTestCase { + func testTimedStartEdgeTeachesVerticalNudge() { + let text = EdgeFocusHint.statusText( + edge: .startDate, + isAllDay: false, + announcement: "Editing start time" + ) + XCTAssertTrue(text.contains("Shift+ArrowUp/ArrowDown adjusts start")) + XCTAssertTrue(text.contains("Tab cycles target")) + XCTAssertFalse(text == "Editing start time") + } + + func testNudgeResultKeepsTimeLabel() { + let text = EdgeFocusHint.statusText( + edge: .startDate, + isAllDay: false, + announcement: "Start 9:15 AM" + ) + XCTAssertTrue(text.hasPrefix("Start 9:15 AM")) + XCTAssertTrue(text.contains("adjusts start")) + } + + func testAllDayWholeEventUsesHorizontalArrows() { + let text = EdgeFocusHint.statusText( + edge: nil, + isAllDay: true, + announcement: "Editing whole event" + ) + XCTAssertTrue(text.contains("ArrowLeft/ArrowRight")) + XCTAssertTrue(text.contains("moves event")) + XCTAssertFalse(text.contains("ArrowUp")) + } + + func testAllDayStartEdgeUsesHorizontalArrows() { + let text = EdgeFocusHint.statusText( + edge: .startDate, + isAllDay: true, + announcement: "Editing start time" + ) + XCTAssertTrue(text.contains("ArrowLeft/ArrowRight adjusts start date")) + } + + func testTimedEndEdgeAfterNudgeShowsConcreteEndTime() { + let text = EdgeFocusHint.statusText( + edge: .endDate, + isAllDay: false, + announcement: "End 5:00 PM" + ) + XCTAssertTrue(text.hasPrefix("End 5:00 PM")) + XCTAssertTrue(text.contains("ArrowUp/ArrowDown adjusts end")) + XCTAssertFalse(text.contains("Editing end time")) + } + + @MainActor + func testFocusStoreKeepsAllDayFlagForEdgeHints() { + let store = FocusStore(view: .week) + let id = EventId(rawValue: "all-day-1") + store.register(eventId: id, eventType: .allDay) + store.setEdge( + eventId: id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: true + ) + XCTAssertTrue(store.edgeFocusIsAllDay) + XCTAssertEqual(store.edge, .startDate) + + let hint = EdgeFocusHint.statusText( + edge: store.edge, + isAllDay: store.edgeFocusIsAllDay, + announcement: store.edgeAnnouncement + ) + XCTAssertTrue(hint.contains("ArrowLeft/ArrowRight adjusts start date")) + + store.setEdge( + eventId: id, + edge: .endDate, + announcement: "Editing end time", + isAllDay: true + ) + XCTAssertEqual(store.edge, .endDate) + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift index 3d788d330..926b18f41 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift @@ -288,6 +288,9 @@ public struct RootView: View { .padding(.horizontal, 16) .padding(.bottom, 2) } + EdgeFocusStatusLine(model: model) + .padding(.horizontal, 16) + .padding(.bottom, 2) SidebarSyncStatusLine(model: model) .padding(.horizontal, 16) .padding(.bottom, 4) @@ -296,7 +299,9 @@ public struct RootView: View { isEventFormVisible: model.isEventFormVisible, isLifeView: model.viewStore.view == .life, lifeThisWeekEligible: model.lifeStore.hasBirthDate, - suppressTips: model.eventJumpStatusText != nil || model.focusStore.pageJumpHintsVisible, + suppressTips: model.eventJumpStatusText != nil + || model.edgeFocusStatusText != nil + || model.focusStore.pageJumpHintsVisible, suppressForQuickTime: !model.draftStore.quickTimeDigits.isEmpty) .padding(.horizontal, 16) .padding(.bottom, 8) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EdgeFocusStatusLine.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EdgeFocusStatusLine.swift new file mode 100644 index 000000000..d97b846f8 --- /dev/null +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EdgeFocusStatusLine.swift @@ -0,0 +1,21 @@ +import CompassData +import SwiftUI + +/// Web `EdgeFocusIndicator` while an event edge or whole-body move is focused. +struct EdgeFocusStatusLine: View { + @Environment(\.nativeWebTheme) private var theme + @Bindable var model: NativeCalendarRootModel + + var body: some View { + if let text = model.edgeFocusStatusText { + Text(text) + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("compass-native-edge-focus-status") + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut(duration: 0.2), value: text) + } + } +} diff --git a/apps/calendar-web/src/components/Sidebar/SidebarStatusBar.test.tsx b/apps/calendar-web/src/components/Sidebar/SidebarStatusBar.test.tsx index 50f375ca6..8ebcb7f1e 100644 --- a/apps/calendar-web/src/components/Sidebar/SidebarStatusBar.test.tsx +++ b/apps/calendar-web/src/components/Sidebar/SidebarStatusBar.test.tsx @@ -602,12 +602,16 @@ describe("SidebarStatusBar", () => { "aaaaaaaaaaaaaaaaaaaaaaaa", "startDate", "Editing start time", + false, ); const { wrapper } = createStoreWrapper(); render(, { wrapper }); - expect(screen.getByRole("status")).toHaveTextContent("Editing start time"); + expect(screen.getByRole("status")).not.toHaveTextContent( + /^Editing start time$/, + ); + expect(screen.getByRole("status")).toHaveTextContent("adjusts start"); expect(screen.queryByText(KEYBOARD_PLACE_HINT)).not.toBeInTheDocument(); }); diff --git a/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.test.tsx b/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.test.tsx new file mode 100644 index 000000000..6b8e0946b --- /dev/null +++ b/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { EdgeFocusIndicator } from "@web/grid/shortcuts/EdgeFocusIndicator"; +import { edgeFocusActions } from "@web/grid/shortcuts/edge-focus.store"; +import { edgeFocusHintParts } from "@web/grid/shortcuts/edge-focus-hint.parts"; +import { getPartsPlainText } from "@web/shortcuts/tips/shortcut-tips.data"; +import { afterEach, describe, expect, it } from "bun:test"; + +await import("@testing-library/jest-dom"); + +afterEach(() => { + edgeFocusActions.reset(); +}); + +describe("EdgeFocusIndicator", () => { + it("renders actionable hints instead of a mode-only label", () => { + edgeFocusActions.setEdge( + "event-1", + "startDate", + "Editing start time", + false, + ); + render(); + + expect(screen.getByRole("status")).not.toHaveTextContent( + /^Editing start time$/, + ); + expect(screen.getByRole("status")).toHaveTextContent( + getPartsPlainText( + edgeFocusHintParts("startDate", false, "Editing start time"), + ), + ); + }); +}); diff --git a/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.tsx b/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.tsx index 10f050547..0264fea82 100644 --- a/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.tsx +++ b/apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.tsx @@ -3,16 +3,21 @@ import { selectEdgeFocusAnnouncement, useEdgeFocusStore, } from "@web/grid/shortcuts/edge-focus.store"; +import { edgeFocusHintParts } from "@web/grid/shortcuts/edge-focus-hint.parts"; +import { ShortcutTipParts } from "@web/shortcuts/tips/ShortcutTipParts"; /** - * Live region announcing the currently focused start/end edge and the result - * of edge nudges (e.g. "Editing start time", "Start 9:15 AM"). + * Sidebar contextual help while an event edge or whole-body move is focused. */ export const EdgeFocusIndicator: FC = () => { const announcement = useEdgeFocusStore(selectEdgeFocusAnnouncement); + const edge = useEdgeFocusStore((state) => state.edge); + const isAllDay = useEdgeFocusStore((state) => state.isAllDay); if (!announcement) return null; + const parts = edgeFocusHintParts(edge, isAllDay, announcement); + return ( { data-edge-focus-indicator="" role="status" > - {announcement} + ); }; diff --git a/apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.test.ts b/apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.test.ts new file mode 100644 index 000000000..ca55872fd --- /dev/null +++ b/apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.test.ts @@ -0,0 +1,32 @@ +import { edgeFocusHintParts } from "@web/grid/shortcuts/edge-focus-hint.parts"; +import { getPartsPlainText } from "@web/shortcuts/tips/shortcut-tips.data"; +import { describe, expect, it } from "bun:test"; + +describe("edgeFocusHintParts", () => { + it("teaches timed start-edge nudges", () => { + const text = getPartsPlainText( + edgeFocusHintParts("startDate", false, "Editing start time"), + ); + expect(text).toContain("Shift"); + expect(text).toMatch(/adjusts start/i); + expect(text).toMatch(/Tab cycles target/i); + expect(text).not.toBe("Editing start time"); + }); + + it("keeps a nudge result and still teaches the next adjustment", () => { + const text = getPartsPlainText( + edgeFocusHintParts("startDate", false, "Start 9:15 AM"), + ); + expect(text).toContain("Start 9:15 AM"); + expect(text).toMatch(/adjusts start/i); + }); + + it("uses horizontal arrows for all-day whole-event moves", () => { + const text = getPartsPlainText( + edgeFocusHintParts(null, true, "Editing whole event"), + ); + expect(text).toMatch(/ArrowLeft/); + expect(text).toMatch(/moves event/i); + expect(text).not.toContain("ArrowUp"); + }); +}); diff --git a/apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.ts b/apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.ts new file mode 100644 index 000000000..35a97a5aa --- /dev/null +++ b/apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.ts @@ -0,0 +1,60 @@ +import { KEYMAP } from "@core/shortcuts/keymap"; +import { type EventEdge } from "@web/common/utils/event/event-nudge.util"; +import { type ShortcutTipPart } from "@web/shortcuts/tips/shortcut-tips.data"; + +const sep: ShortcutTipPart = " · "; + +const MODE_ANNOUNCEMENTS = new Set([ + "Editing start time", + "Editing end time", + "Editing whole event", +]); + +const wholeEventMoveKeys = (isAllDay: boolean): string[] => + isAllDay + ? ["Shift", "ArrowLeft", "ArrowRight"] + : ["Shift", "ArrowLeft", "ArrowUp", "ArrowDown", "ArrowRight"]; + +const edgeMoveKeys = (isAllDay: boolean): string[] => + isAllDay + ? ["Shift", "ArrowLeft", "ArrowRight"] + : [...KEYMAP.moveEvent.timedEdgeKeycaps]; + +/** Sidebar education while an event edge or whole-body move focus is active. */ +export const edgeFocusHintParts = ( + edge: EventEdge | null, + isAllDay: boolean, + announcement: string, +): readonly ShortcutTipPart[] => { + const movement = movementParts(edge, isAllDay); + const cycle: readonly ShortcutTipPart[] = [ + { key: KEYMAP.edgeFocus.hotkey }, + " cycles target", + ]; + + if (announcement && !MODE_ANNOUNCEMENTS.has(announcement)) { + return [announcement, sep, ...movement, sep, ...cycle]; + } + + return [...movement, sep, ...cycle]; +}; + +function movementParts( + edge: EventEdge | null, + isAllDay: boolean, +): readonly ShortcutTipPart[] { + if (edge === null) { + return [{ keys: wholeEventMoveKeys(isAllDay) }, " moves event"]; + } + + const noun = + edge === "startDate" + ? isAllDay + ? "start date" + : "start" + : isAllDay + ? "end date" + : "end"; + + return [{ keys: edgeMoveKeys(isAllDay) }, ` adjusts ${noun}`]; +} diff --git a/apps/calendar-web/src/grid/shortcuts/edge-focus.store.ts b/apps/calendar-web/src/grid/shortcuts/edge-focus.store.ts index b7a7f3cab..95b96b123 100644 --- a/apps/calendar-web/src/grid/shortcuts/edge-focus.store.ts +++ b/apps/calendar-web/src/grid/shortcuts/edge-focus.store.ts @@ -8,12 +8,15 @@ export type EdgeFocusState = { edge: EventEdge | null; /** Polite live-region message for the current edge / a nudge result. */ announcement: string; + /** Drives whether edge nudges teach vertical time or horizontal day shifts. */ + isAllDay: boolean; }; export const initialEdgeFocusState: EdgeFocusState = { eventId: null, edge: null, announcement: "", + isAllDay: false, }; export const useEdgeFocusStore = create()( @@ -34,7 +37,11 @@ const edgeAnnouncement = (edge: EventEdge | null) => export const edgeFocusActions = { /** Cycles the given event's edge focus: whole event → start → end → wrap. */ - cycle: (eventId: string, direction: "forward" | "backward") => { + cycle: ( + eventId: string, + direction: "forward" | "backward", + isAllDay = false, + ) => { const current = useEdgeFocusStore.getState(); const currentEdge = current.eventId === eventId ? current.edge : null; const index = EDGE_CYCLE.indexOf(currentEdge); @@ -43,16 +50,30 @@ export const edgeFocusActions = { EDGE_CYCLE[(index + step + EDGE_CYCLE.length) % EDGE_CYCLE.length]!; useEdgeFocusStore.setState( - { eventId, edge: nextEdge, announcement: edgeAnnouncement(nextEdge) }, + { + eventId, + edge: nextEdge, + announcement: edgeAnnouncement(nextEdge), + isAllDay, + }, false, { type: "cycle" }, ); }, /** Sets the focused edge directly (e.g. after a keyboard nudge flip). */ - setEdge: (eventId: string, edge: EventEdge, announcement: string) => - useEdgeFocusStore.setState({ eventId, edge, announcement }, false, { - type: "setEdge", - }), + setEdge: ( + eventId: string, + edge: EventEdge, + announcement: string, + isAllDay = false, + ) => + useEdgeFocusStore.setState( + { eventId, edge, announcement, isAllDay }, + false, + { + type: "setEdge", + }, + ), reset: () => useEdgeFocusStore.setState(initialEdgeFocusState, false, { type: "reset", diff --git a/apps/calendar-web/src/grid/shortcuts/useGridEventEditShortcuts.ts b/apps/calendar-web/src/grid/shortcuts/useGridEventEditShortcuts.ts index da2eeecb0..865969d8f 100644 --- a/apps/calendar-web/src/grid/shortcuts/useGridEventEditShortcuts.ts +++ b/apps/calendar-web/src/grid/shortcuts/useGridEventEditShortcuts.ts @@ -497,6 +497,7 @@ export function useGridEventEditShortcuts({ event._id!, nextEdge, describeEdgeDate(nudgedEvent, nextEdge), + Boolean(nudgedEvent.isAllDay), ); afterNudge?.(nudgedEvent); }, @@ -668,7 +669,11 @@ export function useGridEventEditShortcuts({ } claimShortcut(keyboardEvent); - edgeFocusActions.cycle(event._id, forward ? "forward" : "backward"); + edgeFocusActions.cycle( + event._id, + forward ? "forward" : "backward", + Boolean(event.isAllDay), + ); }; const onEscape = () => { diff --git a/e2e/timed/edge-focus-sidebar-hint-0084.spec.ts b/e2e/timed/edge-focus-sidebar-hint-0084.spec.ts new file mode 100644 index 000000000..b189a4afb --- /dev/null +++ b/e2e/timed/edge-focus-sidebar-hint-0084.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; +import { + createEventTitle, + fillTitleAndSaveEventForm, + openTimedEventFormWithKeyboard, + prepareTimedCalendarPage, +} from "../utils/event-test-utils"; +import path from "node:path"; + +const ARTIFACT_DIR = "/opt/cursor/artifacts"; + +test("@artifact edge focus sidebar teaches nudge keys", async ({ page }) => { + await prepareTimedCalendarPage(page); + const title = createEventTitle("Edge Hint"); + await openTimedEventFormWithKeyboard(page); + await fillTitleAndSaveEventForm(page, title); + + const eventButton = page + .locator("#mainGrid") + .getByRole("button", { name: title }); + await eventButton.focus(); + await page.keyboard.press("Tab"); + + const status = page.locator("[data-edge-focus-indicator]"); + await expect(status).toContainText(/adjusts start/i); + await expect(status).toContainText(/Tab/i); + await expect(status).not.toHaveText(/^Editing start time$/); + + await page.screenshot({ + path: path.join(ARTIFACT_DIR, "edge-focus-start-time-sidebar-hint.png"), + }); + + await page.keyboard.press("Shift+ArrowUp"); + await expect(status).toContainText(/Start /); + await expect(status).toContainText(/adjusts start/i); +});