diff --git a/.changeset/calm-calendars-hover.md b/.changeset/calm-calendars-hover.md new file mode 100644 index 0000000000..c1751380c9 --- /dev/null +++ b/.changeset/calm-calendars-hover.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Export the Hover Card portal for app-level overlay composition. diff --git a/packages/toolkit/src/ui/hover-card.tsx b/packages/toolkit/src/ui/hover-card.tsx index 873b65ef45..747e642391 100644 --- a/packages/toolkit/src/ui/hover-card.tsx +++ b/packages/toolkit/src/ui/hover-card.tsx @@ -7,6 +7,8 @@ const HoverCard = HoverCardPrimitive.Root; const HoverCardTrigger = HoverCardPrimitive.Trigger; +const HoverCardPortal = HoverCardPrimitive.Portal; + const HoverCardContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -24,4 +26,4 @@ const HoverCardContent = React.forwardRef< )); HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; -export { HoverCard, HoverCardTrigger, HoverCardContent }; +export { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent }; diff --git a/templates/calendar/app/components/calendar/EventCard.test.tsx b/templates/calendar/app/components/calendar/EventCard.test.tsx new file mode 100644 index 0000000000..f08400e8a2 --- /dev/null +++ b/templates/calendar/app/components/calendar/EventCard.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +import type { CalendarEvent } from "@shared/api"; +import { createRef } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { EventCard } from "./EventCard"; + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: + () => + (key: string): string => + key, +})); + +const event: CalendarEvent = { + id: "event-1", + title: "Planning session", + description: "", + location: "Room A", + start: "2026-08-08T17:00:00.000Z", + end: "2026-08-08T18:00:00.000Z", + allDay: false, + source: "local", + createdAt: "2026-08-03T12:00:00.000Z", + updatedAt: "2026-08-03T12:00:00.000Z", + attendees: [], +}; + +describe("EventCard", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("forwards trigger props and its ref to the underlying button", () => { + const ref = createRef(); + const onPointerEnter = vi.fn(); + + act(() => { + root.render( + , + ); + }); + + const button = container.querySelector("button"); + expect(ref.current).toBe(button); + expect(button?.classList.contains("preview-trigger")).toBe(true); + expect(button?.getAttribute("data-state")).toBe("closed"); + + act(() => { + button?.dispatchEvent(new PointerEvent("pointerover", { bubbles: true })); + }); + + expect(onPointerEnter).toHaveBeenCalledOnce(); + }); +}); diff --git a/templates/calendar/app/components/calendar/EventCard.tsx b/templates/calendar/app/components/calendar/EventCard.tsx index b9410148c0..c3c93631a8 100644 --- a/templates/calendar/app/components/calendar/EventCard.tsx +++ b/templates/calendar/app/components/calendar/EventCard.tsx @@ -1,6 +1,7 @@ import { useT } from "@agent-native/core/client/i18n"; import type { CalendarEvent } from "@shared/api"; import { IconAlertTriangleFilled, IconCalendarOff } from "@tabler/icons-react"; +import { forwardRef, type ButtonHTMLAttributes } from "react"; import { getEventDisplayColor, @@ -17,9 +18,11 @@ import { isWorkingLocationEvent, } from "@/lib/working-location"; -interface EventCardProps { +interface EventCardProps extends Omit< + ButtonHTMLAttributes, + "draggable" | "onDragStart" | "onDragEnd" +> { event: CalendarEvent; - onClick?: () => void; compact?: boolean; draggable?: boolean; onDragStart?: (id: string) => void; @@ -28,155 +31,168 @@ interface EventCardProps { colorPreferences?: CalendarColorPreferences; } -export function EventCard({ - event, - onClick, - compact = false, - draggable = false, - onDragStart, - onDragEnd, - dimmed = false, - colorPreferences, -}: EventCardProps) { - const t = useT(); - const workingLocationLabels = createWorkingLocationDisplayLabels(t); - const accentColor = getEventDisplayColor(event, colorPreferences); - const ownerLabel = event.ownerName || event.overlayEmail; - const title = getWorkingLocationChipLabel(event, workingLocationLabels); - const ariaTitle = getWorkingLocationTitle(event, workingLocationLabels); - const isWorkingLocation = isWorkingLocationEvent(event); - const isOutOfOffice = isOutOfOfficeEvent(event); +export const EventCard = forwardRef( + function EventCard( + { + event, + compact = false, + draggable = false, + onDragStart, + onDragEnd, + dimmed = false, + colorPreferences, + className, + style, + ...buttonProps + }, + forwardedRef, + ) { + const t = useT(); + const workingLocationLabels = createWorkingLocationDisplayLabels(t); + const accentColor = getEventDisplayColor(event, colorPreferences); + const ownerLabel = event.ownerName || event.overlayEmail; + const title = getWorkingLocationChipLabel(event, workingLocationLabels); + const ariaTitle = getWorkingLocationTitle(event, workingLocationLabels); + const isWorkingLocation = isWorkingLocationEvent(event); + const isOutOfOffice = isOutOfOfficeEvent(event); - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setData("text/plain", event.id); - e.dataTransfer.effectAllowed = "move"; - onDragStart?.(event.id); - }; + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setData("text/plain", event.id); + e.dataTransfer.effectAllowed = "move"; + onDragStart?.(event.id); + }; - const canDrag = draggable && !event.overlayEmail; + const canDrag = draggable && !event.overlayEmail; + + if (compact) { + return ( + + ); + } - if (compact) { return ( ); - } - - return ( - - ); -} + }, +); diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx index a8bd3a6729..e2572d1558 100644 --- a/templates/calendar/app/components/calendar/EventDetailPopover.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -35,6 +35,7 @@ import { RenderedDescription, AutoGrowTextarea, } from "@/components/calendar/EventDescription"; +import { EventHoverPreview } from "@/components/calendar/EventHoverPreview"; import { AttachmentControls, ReminderControls, @@ -96,6 +97,7 @@ import { type ReminderMode, validateAttachmentDrafts, } from "@/lib/event-form-utils"; +import { extractMeetingLink } from "@/lib/event-meeting"; import { isOutOfOfficeEvent } from "@/lib/out-of-office"; import { createEventDetailPopoverToken, @@ -198,54 +200,6 @@ function formatTimeShort(dateStr: string): string { return `${hour12}:${m.toString().padStart(2, "0")} ${period}`; } -/** Extract a Zoom/Meet/Teams link from location or description */ -function extractMeetingLink(event: CalendarEvent): { - url: string; - type: "zoom" | "meet" | "teams" | "link"; - label?: string; - pin?: string; - passcode?: string; -} | null { - if (event.meetingLink) { - return { url: event.meetingLink, type: getMeetingType(event.meetingLink) }; - } - - // Check conferenceData first - if (event.conferenceData?.entryPoints) { - const videoEntry = event.conferenceData.entryPoints.find( - (ep) => ep.entryPointType === "video", - ); - if (videoEntry) { - let type: "zoom" | "meet" | "teams" | "link" = "link"; - if (videoEntry.uri.includes("zoom.us")) type = "zoom"; - else if (videoEntry.uri.includes("meet.google.com")) type = "meet"; - else if (videoEntry.uri.includes("teams.microsoft.com")) type = "teams"; - return { - url: videoEntry.uri, - type, - label: videoEntry.label || undefined, - pin: videoEntry.pin || undefined, - passcode: videoEntry.passcode || undefined, - }; - } - } - - // Fall back to the legacy hangoutLink (Google Meet) - if (event.hangoutLink) { - return { url: event.hangoutLink, type: "meet" }; - } - - // Fall back to text matching - const text = `${event.location || ""} ${event.description || ""}`; - const zoom = text.match(/https?:\/\/[^\s]*zoom\.us\/j\/[^\s)"]*/i); - if (zoom) return { url: zoom[0], type: "zoom" }; - const meet = text.match(/https?:\/\/meet\.google\.com\/[^\s)"]*/i); - if (meet) return { url: meet[0], type: "meet" }; - const teams = text.match(/https?:\/\/teams\.microsoft\.com\/[^\s)"]*/i); - if (teams) return { url: teams[0], type: "teams" }; - return null; -} - function getMeetingLabel( type: "zoom" | "meet" | "teams" | "link", t: ReturnType, @@ -262,13 +216,6 @@ function getMeetingLabel( } } -function getMeetingType(url: string): "zoom" | "meet" | "teams" | "link" { - if (url.includes("zoom.us")) return "zoom"; - if (url.includes("meet.google.com")) return "meet"; - if (url.includes("teams.microsoft.com")) return "teams"; - return "link"; -} - function MeetingLinkSkeleton({ provider }: { provider: "meet" | "zoom" }) { const t = useT(); return ( @@ -500,6 +447,10 @@ interface EventDetailPopoverProps { onDismissNew?: (eventId: string, accountEmail?: string) => void; /** Called after the popover's visible open state changes through its normal lifecycle. */ onOpenChange?: (open: boolean) => void; + /** Adds the compact, read-only Month/Week disclosure without changing click details. */ + showHoverPreview?: boolean; + /** Temporarily suppresses the preview while a parent owns pointer drag state. */ + hoverPreviewDisabled?: boolean; onDraftUpdate?: ( eventId: string, updates: Partial & { @@ -528,6 +479,8 @@ export function EventDetailPopover({ onTitleSave, onDismissNew, onOpenChange, + showHoverPreview = false, + hoverPreviewDisabled = false, onDraftUpdate, onDraftCreate, onDraftDiscard, @@ -1431,6 +1384,8 @@ export function EventDetailPopover({ sidebarEvent?.id === event.id && sidebarEvent.accountEmail === event.accountEmail; const detailsOpen = popoverOpen || sidebarDetailsOpen; + const hoverPreviewEnabled = + showHoverPreview && !isMobile && !isWorkingLocation && !isOutOfOffice; const previousDetailsOpenRef = useRef(false); useEffect(() => { @@ -1449,7 +1404,16 @@ export function EventDetailPopover({ return ( - {children} + {hoverPreviewEnabled ? ( + + {children as React.ReactElement<{ className?: string }>} + + ) : ( + children + )} ({ + useT: + () => + (key: string): string => + key, +})); + +vi.mock("@/components/ui/hover-card", () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardPortal: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ + children, + side, + align, + }: { + children: ReactNode; + side: string; + align: string; + }) => ( + + ), +})); + +function event(overrides: Partial = {}): CalendarEvent { + return { + id: "event-1", + title: "A planning session with a deliberately long title", + description: "", + location: "Room A", + start: "2026-07-10T16:00:00.000Z", + end: "2026-07-10T17:00:00.000Z", + allDay: false, + source: "google", + createdAt: "2026-07-10T15:00:00.000Z", + updatedAt: "2026-07-10T15:00:00.000Z", + attendees: [], + ...overrides, + }; +} + +describe("EventHoverPreview", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("renders the event once beside its trigger with available details", () => { + act(() => { + root.render( + + + , + ); + }); + + const preview = container.querySelector('[data-testid="preview"]'); + expect(preview?.getAttribute("data-side")).toBe("right"); + expect(preview?.getAttribute("data-align")).toBe("center"); + expect( + container.textContent?.match(/deliberately long title/g), + ).toHaveLength(1); + expect(container.textContent).toContain("Room A"); + expect(container.textContent).toContain("Alice, brent@example.com, Sam +1"); + + const meetingLink = container.querySelector( + 'a[href="https://meet.google.com/abc-defg-hij"]', + ); + expect(meetingLink?.textContent).toContain("eventForm.joinMeet"); + expect(meetingLink?.target).toBe("_blank"); + expect(meetingLink?.rel).toBe("noopener noreferrer"); + }); + + it("omits unavailable optional rows and avoids duplicating a meeting URL", () => { + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).not.toContain("Join https://zoom.us/j/123"); + expect(container.textContent).toContain("eventForm.joinZoom"); + expect(container.querySelectorAll("svg")).toHaveLength(3); + }); +}); diff --git a/templates/calendar/app/components/calendar/EventHoverPreview.tsx b/templates/calendar/app/components/calendar/EventHoverPreview.tsx new file mode 100644 index 0000000000..305428b922 --- /dev/null +++ b/templates/calendar/app/components/calendar/EventHoverPreview.tsx @@ -0,0 +1,202 @@ +import { useT } from "@agent-native/core/client/i18n"; +import type { CalendarEvent } from "@shared/api"; +import { + IconClock, + IconExternalLink, + IconMapPin, + IconUsers, + IconVideo, +} from "@tabler/icons-react"; +import { format, isSameDay, parseISO } from "date-fns"; +import { + cloneElement, + forwardRef, + useEffect, + useMemo, + useState, + type ElementRef, + type HTMLAttributes, + type ReactElement, +} from "react"; + +import { + HoverCard, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { extractMeetingLink } from "@/lib/event-meeting"; +import { cn } from "@/lib/utils"; + +interface EventHoverPreviewProps extends HTMLAttributes { + event: CalendarEvent; + children: ReactElement<{ className?: string }>; + disabled?: boolean; +} + +function formatPreviewTime(event: CalendarEvent, allDayLabel: string): string { + const start = parseISO(event.start); + const end = parseISO(event.end); + + if (event.allDay) { + return `${format(start, "EEE, MMM d")} · ${allDayLabel}`; + } + if (isSameDay(start, end)) { + return `${format(start, "EEE, MMM d")} · ${format(start, "h:mm a")}–${format(end, "h:mm a")}`; + } + return `${format(start, "EEE, MMM d · h:mm a")}–${format(end, "EEE, MMM d · h:mm a")}`; +} + +export const EventHoverPreview = forwardRef< + ElementRef, + EventHoverPreviewProps +>(function EventHoverPreview( + { + event, + children, + disabled = false, + onPointerDown, + onPointerUp, + ...triggerProps + }, + forwardedRef, +) { + const t = useT(); + const [open, setOpen] = useState(false); + const [pointerDown, setPointerDown] = useState(false); + const meetingLink = useMemo(() => extractMeetingLink(event), [event]); + const attendees = event.attendees ?? []; + const visibleAttendees = attendees.slice(0, 3); + const attendeeRemainder = attendees.length - visibleAttendees.length; + + useEffect(() => { + if (disabled || pointerDown) setOpen(false); + }, [disabled, pointerDown]); + + useEffect(() => { + if (!pointerDown) return; + const releasePointer = () => setPointerDown(false); + window.addEventListener("pointerup", releasePointer, { once: true }); + window.addEventListener("pointercancel", releasePointer, { once: true }); + return () => { + window.removeEventListener("pointerup", releasePointer); + window.removeEventListener("pointercancel", releasePointer); + }; + }, [pointerDown]); + + const meetingLabel = meetingLink + ? meetingLink.type === "zoom" + ? t("eventForm.joinZoom") + : meetingLink.type === "meet" + ? t("eventForm.joinMeet") + : meetingLink.type === "teams" + ? t("eventForm.joinTeams") + : t("eventForm.joinMeeting") + : null; + const previewOpen = open && !disabled && !pointerDown; + const trigger = cloneElement(children, { + className: cn( + children.props.className, + previewOpen && "ring-2 ring-ring/50 ring-offset-1 ring-offset-background", + ), + }); + + return ( + { + if (!disabled && !pointerDown) setOpen(nextOpen); + }} + openDelay={150} + closeDelay={150} + > + { + setPointerDown(true); + setOpen(false); + onPointerDown?.(pointerEvent); + }} + onPointerUp={(pointerEvent) => { + setPointerDown(false); + onPointerUp?.(pointerEvent); + }} + {...triggerProps} + > + {trigger} + + + clickEvent.stopPropagation()} + > +

+ {event.title} +

+ +
+
+
+ + {event.location && + (!meetingLink || !event.location.includes(meetingLink.url)) && ( +
+
+ )} + + {attendees.length > 0 && ( +
+
+ )} +
+ + {meetingLink && meetingLabel && ( + pointerEvent.stopPropagation()} + onClick={(clickEvent) => clickEvent.stopPropagation()} + > + + + + )} +
+
+
+ ); +}); diff --git a/templates/calendar/app/components/calendar/MonthView.tsx b/templates/calendar/app/components/calendar/MonthView.tsx index 5ef28b7779..191903ecc7 100644 --- a/templates/calendar/app/components/calendar/MonthView.tsx +++ b/templates/calendar/app/components/calendar/MonthView.tsx @@ -271,31 +271,33 @@ export const MonthView = memo(function MonthView({ dayOccurrences .slice(0, isMobile ? 2 : 3) .map(({ event, isStart, continuesNext }) => ( - {})} - isDraft={draftEventIds.includes(event.id)} - onDraftUpdate={onDraftUpdate} - onDraftCreate={onDraftCreate} - onDraftDiscard={onDraftDiscard} + onClick={(e) => e.stopPropagation()} + onDragStart={(e) => { + if (!isStart) return; + const ghost = e.currentTarget.querySelector( + "button", + ) as HTMLElement | null; + if (ghost) { + e.dataTransfer.setDragImage(ghost, 12, 12); + } + }} + className={cn( + "relative rounded-sm", + !isStart && + "-ml-1 -mr-1 border-l-2 border-dashed border-current pl-[calc(0.25rem-2px)] opacity-90 sm:-ml-1.5 sm:-mr-1.5 sm:pl-[calc(0.375rem-2px)]", + )} > -
e.stopPropagation()} - onDragStart={(e) => { - if (!isStart) return; - const ghost = e.currentTarget.querySelector( - "button", - ) as HTMLElement | null; - if (ghost) { - e.dataTransfer.setDragImage(ghost, 12, 12); - } - }} - className={cn( - "relative", - !isStart && - "-ml-1 -mr-1 border-l-2 border-dashed border-current pl-[calc(0.25rem-2px)] opacity-90 sm:-ml-1.5 sm:-mr-1.5 sm:pl-[calc(0.375rem-2px)]", - )} + {})} + isDraft={draftEventIds.includes(event.id)} + onDraftUpdate={onDraftUpdate} + onDraftCreate={onDraftCreate} + onDraftDiscard={onDraftDiscard} + showHoverPreview + hoverPreviewDisabled={draggingId !== null} > - {continuesNext && ( - - )} -
-
+ + {continuesNext && ( + + )} + ))} {!isLoading && dayOccurrences.length > (isMobile ? 2 : 3) && (