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
@@ -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<String> = [
"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)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -98,6 +110,7 @@ public final class FocusStore {
focusedEventType = nil
edge = nil
edgeAnnouncement = ""
edgeFocusIsAllDay = false
}

public func setPageJumpTargets(_ targets: [PageJumpTarget]) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -602,12 +602,16 @@ describe("SidebarStatusBar", () => {
"aaaaaaaaaaaaaaaaaaaaaaaa",
"startDate",
"Editing start time",
false,
);
const { wrapper } = createStoreWrapper();

render(<SidebarStatusBar />, { 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();
});

Expand Down
33 changes: 33 additions & 0 deletions apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EdgeFocusIndicator />);

expect(screen.getByRole("status")).not.toHaveTextContent(
/^Editing start time$/,
);
expect(screen.getByRole("status")).toHaveTextContent(
getPartsPlainText(
edgeFocusHintParts("startDate", false, "Editing start time"),
),
);
});
});
11 changes: 8 additions & 3 deletions apps/calendar-web/src/grid/shortcuts/EdgeFocusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,29 @@ 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 (
<span
aria-live="polite"
className="block w-full text-pretty break-words text-center text-text-muted text-xs leading-5 opacity-80"
data-edge-focus-indicator=""
role="status"
>
{announcement}
<ShortcutTipParts parts={parts} />
</span>
);
};
32 changes: 32 additions & 0 deletions apps/calendar-web/src/grid/shortcuts/edge-focus-hint.parts.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading