From 2ba209541372184cc2146c05f58f307ad47d10e2 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Sun, 31 May 2026 09:27:50 -0500 Subject: [PATCH 1/5] fix(web): handle calendar event keyboard bugs --- .../components/CalendarAllDayEventCard.tsx | 1 + .../components/CalendarEventCard.test.tsx | 48 ++++++++ .../components/CalendarTimedEventCard.tsx | 1 + .../hooks/state/useDraftConfirmation.test.tsx | 107 ++++++++++++++++++ .../Draft/hooks/state/useDraftConfirmation.ts | 4 +- 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx diff --git a/packages/web/src/common/calendar-grid/components/CalendarAllDayEventCard.tsx b/packages/web/src/common/calendar-grid/components/CalendarAllDayEventCard.tsx index 81e89a05b..42f264b0c 100644 --- a/packages/web/src/common/calendar-grid/components/CalendarAllDayEventCard.tsx +++ b/packages/web/src/common/calendar-grid/components/CalendarAllDayEventCard.tsx @@ -126,6 +126,7 @@ const CalendarAllDayEventCardBase = ( } e.preventDefault(); + e.stopPropagation(); if (isPending) { return; } diff --git a/packages/web/src/common/calendar-grid/components/CalendarEventCard.test.tsx b/packages/web/src/common/calendar-grid/components/CalendarEventCard.test.tsx index a230b5b18..6d233b8bf 100644 --- a/packages/web/src/common/calendar-grid/components/CalendarEventCard.test.tsx +++ b/packages/web/src/common/calendar-grid/components/CalendarEventCard.test.tsx @@ -109,6 +109,29 @@ describe("CalendarEventCard", () => { expect(onEventKeyDown).not.toHaveBeenCalled(); }); + it("keeps timed event keyboard activation from reaching parent shortcuts", () => { + const onEventKeyDown = mock(); + const onParentKeyDown = mock(); + + render( + // biome-ignore lint/a11y/noStaticElementInteractions: test wrapper simulates a parent shortcut listener. +
+ +
, + ); + + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + + expect(onEventKeyDown).toHaveBeenCalledTimes(1); + expect(onParentKeyDown).not.toHaveBeenCalled(); + }); + it("renders all-day event details, interaction attributes, acknowledgement animation, and resize handles", () => { const onEventMouseDown = mock(); const onScalerMouseDown = mock(); @@ -181,4 +204,29 @@ describe("CalendarEventCard", () => { expect(onEventKeyDown).not.toHaveBeenCalled(); }); + + it("keeps all-day event keyboard activation from reaching parent shortcuts", () => { + const onEventKeyDown = mock(); + const onParentKeyDown = mock(); + + render( + // biome-ignore lint/a11y/noStaticElementInteractions: test wrapper simulates a parent shortcut listener. +
+ +
, + ); + + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + + expect(onEventKeyDown).toHaveBeenCalledTimes(1); + expect(onParentKeyDown).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/common/calendar-grid/components/CalendarTimedEventCard.tsx b/packages/web/src/common/calendar-grid/components/CalendarTimedEventCard.tsx index acb046bd9..6e2c0402e 100644 --- a/packages/web/src/common/calendar-grid/components/CalendarTimedEventCard.tsx +++ b/packages/web/src/common/calendar-grid/components/CalendarTimedEventCard.tsx @@ -218,6 +218,7 @@ const CalendarTimedEventCardBase = ( } e.preventDefault(); + e.stopPropagation(); if (isPending || !onEventKeyDown) { return; } diff --git a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx new file mode 100644 index 000000000..c96b1bd65 --- /dev/null +++ b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx @@ -0,0 +1,107 @@ +import { act, renderHook } from "@testing-library/react"; +import { Origin, Priorities } from "@core/constants/core.constants"; +import { RecurringEventUpdateScope } from "@core/types/event.types"; +import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { useDraftConfirmation } from "./useDraftConfirmation"; +import { describe, expect, it, mock } from "bun:test"; + +const createDraft = ( + overrides: Partial = {}, +): Schema_GridEvent => ({ + _id: "event-1", + title: "Seed event", + startDate: "2026-05-31T10:00:00.000Z", + endDate: "2026-05-31T11:00:00.000Z", + isAllDay: false, + isSomeday: false, + origin: Origin.COMPASS, + priority: Priorities.UNASSIGNED, + user: "user-1", + position: { + isOverlapping: false, + totalEventsInGroup: 1, + widthMultiplier: 1, + horizontalOrder: 1, + dragOffset: { x: 0, y: 0 }, + initialX: null, + initialY: null, + }, + ...overrides, +}); + +const renderDraftConfirmation = ({ + draft = createDraft(), + isInstance = false, + isRecurrence = false, + isSomeday = false, +}: { + draft?: Schema_GridEvent; + isInstance?: boolean; + isRecurrence?: boolean; + isSomeday?: boolean; +} = {}) => { + const discard = mock(); + const deleteEvent = mock(); + const submit = mock(); + + const context = { + actions: { + discard, + deleteEvent, + isInstance: () => isInstance, + isRecurrence: () => isRecurrence, + isSomeday: () => isSomeday, + submit, + }, + state: { + draft, + }, + } as unknown as Parameters[0]; + + const { result } = renderHook(() => useDraftConfirmation(context)); + + return { deleteEvent, discard, result, submit }; +}; + +describe("useDraftConfirmation", () => { + it("submits a new recurring draft without opening the update scope dialog", async () => { + const draft = createDraft({ + _id: undefined, + recurrence: { + rule: ["FREQ=WEEKLY;COUNT=4"], + }, + }); + const { discard, result, submit } = renderDraftConfirmation({ draft }); + + await act(async () => { + await result.current.onSubmit(draft); + }); + + expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(false); + expect(result.current.finalDraft).toBeNull(); + expect(submit).toHaveBeenCalledTimes(1); + expect(submit).toHaveBeenCalledWith( + draft, + RecurringEventUpdateScope.THIS_EVENT, + ); + expect(discard).toHaveBeenCalledTimes(1); + }); + + it("opens the update scope dialog for existing recurring drafts", async () => { + const draft = createDraft({ + recurrence: { + rule: ["FREQ=WEEKLY;COUNT=4"], + }, + }); + const { discard, result, submit } = renderDraftConfirmation({ draft }); + + await act(async () => { + await result.current.onSubmit(draft); + }); + + expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(true); + expect(result.current.finalDraft).toBe(draft); + expect(submit).not.toHaveBeenCalled(); + expect(discard).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts index 8bd274841..b2de4e50b 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts @@ -42,7 +42,9 @@ export const useDraftConfirmation = ({ _draft.recurrence?.eventId ?? "", ); const draftIsRecurring = Array.isArray(rule) || draftIsInstance; - const isRecurringEvent = isRecurrence() || draftIsRecurring; + const isExistingDraft = Boolean(_draft._id) || draftIsInstance; + const isRecurringEvent = + isExistingDraft && (isRecurrence() || draftIsRecurring); const instanceEvent = isInstance() || draftIsInstance; const toStandAlone = instanceEvent && rule === null; const applyTo = toStandAlone From 3f8a8eef860fc1900bca1cb8684cd0349b3e1d26 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Sun, 31 May 2026 09:53:59 -0500 Subject: [PATCH 2/5] fix(web): skip recurrence scope for single occurrence --- .../hooks/state/useDraftConfirmation.test.tsx | 27 ++++++++++++- .../Draft/hooks/state/useDraftConfirmation.ts | 40 ++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx index c96b1bd65..992347ba6 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx +++ b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx @@ -1,4 +1,5 @@ import { act, renderHook } from "@testing-library/react"; +import { ObjectId } from "bson"; import { Origin, Priorities } from "@core/constants/core.constants"; import { RecurringEventUpdateScope } from "@core/types/event.types"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; @@ -87,9 +88,10 @@ describe("useDraftConfirmation", () => { expect(discard).toHaveBeenCalledTimes(1); }); - it("opens the update scope dialog for existing recurring drafts", async () => { + it("opens the update scope dialog for existing multi-occurrence recurring drafts", async () => { const draft = createDraft({ recurrence: { + eventId: new ObjectId().toString(), rule: ["FREQ=WEEKLY;COUNT=4"], }, }); @@ -104,4 +106,27 @@ describe("useDraftConfirmation", () => { expect(submit).not.toHaveBeenCalled(); expect(discard).not.toHaveBeenCalled(); }); + + it("submits a single-occurrence recurring draft without opening the update scope dialog", async () => { + const draft = createDraft({ + recurrence: { + eventId: new ObjectId().toString(), + rule: ["RRULE:FREQ=WEEKLY;COUNT=1"], + }, + }); + const { discard, result, submit } = renderDraftConfirmation({ draft }); + + await act(async () => { + await result.current.onSubmit(draft); + }); + + expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(false); + expect(result.current.finalDraft).toBeNull(); + expect(submit).toHaveBeenCalledTimes(1); + expect(submit).toHaveBeenCalledWith( + draft, + RecurringEventUpdateScope.ALL_EVENTS, + ); + expect(discard).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts index b2de4e50b..dc35baccb 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts @@ -1,9 +1,31 @@ import { ObjectId } from "bson"; import { useCallback, useState } from "react"; import { RecurringEventUpdateScope } from "@core/types/event.types"; +import { CompassEventRRule } from "@core/util/event/compass.event.rrule"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { type useDraftContext } from "@web/views/Week/components/Draft/context/useDraftContext"; +const hasMultipleRecurrenceOccurrences = (event: Schema_GridEvent): boolean => { + const rule = event.recurrence?.rule; + + if (!Array.isArray(rule) || rule.length === 0) { + return true; + } + + try { + const recurrence = new CompassEventRRule({ + _id: new ObjectId(), + startDate: event.startDate, + endDate: event.endDate, + recurrence: { rule }, + }); + + return recurrence.all((_, index) => index < 2).length > 1; + } catch { + return true; + } +}; + export const useDraftConfirmation = ({ actions, state, @@ -47,11 +69,19 @@ export const useDraftConfirmation = ({ isExistingDraft && (isRecurrence() || draftIsRecurring); const instanceEvent = isInstance() || draftIsInstance; const toStandAlone = instanceEvent && rule === null; - const applyTo = toStandAlone - ? RecurringEventUpdateScope.ALL_EVENTS - : RecurringEventUpdateScope.THIS_EVENT; - - if (!toStandAlone && isRecurringEvent) { + const hasMultipleOccurrences = hasMultipleRecurrenceOccurrences(_draft); + const isSingleOccurrenceInstance = + isRecurringEvent && instanceEvent && !hasMultipleOccurrences; + const shouldAskForUpdateScope = + !toStandAlone && + isRecurringEvent && + (hasMultipleOccurrences || !instanceEvent); + const applyTo = + toStandAlone || isSingleOccurrenceInstance + ? RecurringEventUpdateScope.ALL_EVENTS + : RecurringEventUpdateScope.THIS_EVENT; + + if (shouldAskForUpdateScope) { setFinalDraft(_draft); return setRecurrenceUpdateScopeDialogOpen(true); From 2352bc0edc38065c85a90630a7c3e333032796aa Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Sun, 31 May 2026 10:58:57 -0500 Subject: [PATCH 3/5] refactor(web): simplify recurrence scope dialog --- .../LogoutConfirmationDialog.tsx | 26 +-- .../components/OverlayPanel/OverlayPanel.tsx | 43 +++- .../RecurringEventUpdateScopeDialog.tsx | 202 ++++++++++-------- 3 files changed, 160 insertions(+), 111 deletions(-) diff --git a/packages/web/src/components/LogoutConfirmation/LogoutConfirmationDialog.tsx b/packages/web/src/components/LogoutConfirmation/LogoutConfirmationDialog.tsx index 67ceb78a0..ce56d0c15 100644 --- a/packages/web/src/components/LogoutConfirmation/LogoutConfirmationDialog.tsx +++ b/packages/web/src/components/LogoutConfirmation/LogoutConfirmationDialog.tsx @@ -1,4 +1,8 @@ -import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel"; +import { + OverlayPanel, + OverlayPanelActionButton, + OverlayPanelActions, +} from "@web/components/OverlayPanel/OverlayPanel"; interface LogoutConfirmationDialogProps { isOpen: boolean; @@ -20,22 +24,14 @@ export function LogoutConfirmationDialog({ onDismiss={onCancel} variant="modal" > -
- - -
+ + ); } diff --git a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx index 3c9c8bf04..05fa1fb8b 100644 --- a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx +++ b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx @@ -1,5 +1,11 @@ import clsx from "clsx"; -import { type ReactNode, useEffect, useId, useRef } from "react"; +import { + type ButtonHTMLAttributes, + type ReactNode, + useEffect, + useId, + useRef, +} from "react"; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -140,3 +146,38 @@ export const OverlayPanel = ({ ); }; + +interface OverlayPanelActionsProps { + children: ReactNode; +} + +export const OverlayPanelActions = ({ children }: OverlayPanelActionsProps) => ( +
{children}
+); + +interface OverlayPanelActionButtonProps + extends ButtonHTMLAttributes { + variant?: "primary" | "secondary"; +} + +export const OverlayPanelActionButton = ({ + children, + className, + type = "button", + variant = "secondary", + ...buttonProps +}: OverlayPanelActionButtonProps) => ( + +); diff --git a/packages/web/src/views/Forms/EventForm/RecurringEventUpdateScopeDialog.tsx b/packages/web/src/views/Forms/EventForm/RecurringEventUpdateScopeDialog.tsx index 106979854..b3e410212 100644 --- a/packages/web/src/views/Forms/EventForm/RecurringEventUpdateScopeDialog.tsx +++ b/packages/web/src/views/Forms/EventForm/RecurringEventUpdateScopeDialog.tsx @@ -1,125 +1,137 @@ -import { - FloatingFocusManager, - FloatingOverlay, - FloatingPortal, - useClick, - useDismiss, - useFloating, - useInteractions, - useRole, -} from "@floating-ui/react"; -import { useCallback, useMemo, useState } from "react"; -import { Priorities } from "@core/constants/core.constants"; +import { useCallback, useState } from "react"; import { RecurringEventUpdateScope } from "@core/types/event.types"; import { DirtyParser } from "@web/common/parsers/dirty.parser"; -import { theme } from "@web/common/styles/theme"; +import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { + OverlayPanel, + OverlayPanelActionButton, + OverlayPanelActions, +} from "@web/components/OverlayPanel/OverlayPanel"; import { selectDraft } from "@web/ducks/events/selectors/draft.selectors"; import { useAppSelector } from "@web/store/store.hooks"; -import { SaveSection } from "@web/views/Forms/EventForm/SaveSection/SaveSection"; -import { StyledEventForm } from "@web/views/Forms/EventForm/styled"; import { useDraftContext } from "@web/views/Week/components/Draft/context/useDraftContext"; -const UPDATE_SCOPE_OPTIONS: Array<[string, RecurringEventUpdateScope]> = [ - ["this", RecurringEventUpdateScope.THIS_EVENT], - ["this-and-following", RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS], - ["all", RecurringEventUpdateScope.ALL_EVENTS], +const UPDATE_SCOPE_OPTIONS: RecurringEventUpdateScope[] = [ + RecurringEventUpdateScope.THIS_EVENT, + RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS, + RecurringEventUpdateScope.ALL_EVENTS, +]; + +const RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS: RecurringEventUpdateScope[] = [ + RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS, + RecurringEventUpdateScope.ALL_EVENTS, ]; +const updateScopeOptionClassName = + "flex min-h-11 cursor-pointer items-center gap-3 rounded px-3 text-base text-text-lighter transition-colors hover:bg-panel-badge-bg"; + +const selectedUpdateScopeOptionClassName = "bg-panel-badge-bg"; + +const radioDotClassName = + "relative flex size-[18px] flex-none rounded-full border-2 border-border-secondary transition-colors after:absolute after:inset-0 after:m-auto after:size-2 after:scale-0 after:rounded-full after:bg-accent-primary after:transition-transform peer-checked:border-accent-primary peer-checked:after:scale-100 peer-focus-visible:ring-2 peer-focus-visible:ring-accent-primary peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-panel-bg"; + export function RecurringEventUpdateScopeDialog() { const { confirmation, state: { draft }, } = useDraftContext(); - const { isRecurrenceUpdateScopeDialogOpen } = confirmation; - const { setRecurrenceUpdateScopeDialogOpen } = confirmation; - const { onUpdateScopeChange } = confirmation; - const reduxDraft = useAppSelector(selectDraft); - const { UNASSIGNED } = Priorities; - const priority = draft?.priority ?? reduxDraft?.priority ?? UNASSIGNED; + const { + isRecurrenceUpdateScopeDialogOpen, + setRecurrenceUpdateScopeDialogOpen, + onUpdateScopeChange, + } = confirmation; + if (!isRecurrenceUpdateScopeDialogOpen) return null; + + return ( + + ); +} + +interface RecurringEventUpdateScopeDialogContentProps { + draft: Schema_GridEvent | null; + onUpdateScopeChange: (applyTo: RecurringEventUpdateScope) => void; + setRecurrenceUpdateScopeDialogOpen: (isOpen: boolean) => void; +} +function RecurringEventUpdateScopeDialogContent({ + draft, + onUpdateScopeChange, + setRecurrenceUpdateScopeDialogOpen, +}: RecurringEventUpdateScopeDialogContentProps) { + const reduxDraft = useAppSelector(selectDraft); const currentDraft = draft ?? reduxDraft; const recurrenceChanged = currentDraft && reduxDraft ? DirtyParser.recurrenceChanged(currentDraft, reduxDraft) : false; + const options = recurrenceChanged + ? RECURRENCE_CHANGED_UPDATE_SCOPE_OPTIONS + : UPDATE_SCOPE_OPTIONS; + const [fallbackScope] = options; - const { context, refs, floatingStyles } = useFloating({ - open: isRecurrenceUpdateScopeDialogOpen, - onOpenChange: setRecurrenceUpdateScopeDialogOpen, - strategy: "absolute", - placement: "bottom", - transform: true, - }); - - const [value, setValue] = useState( - RecurringEventUpdateScope.THIS_EVENT, - ); - - const click = useClick(context); - const role = useRole(context); - const dismiss = useDismiss(context, { outsidePressEvent: "mousedown" }); - const interactions = useInteractions([click, role, dismiss]); + const [selectedScope, setSelectedScope] = + useState(fallbackScope); + const activeScope = options.includes(selectedScope) + ? selectedScope + : fallbackScope; - const options = useMemo>(() => { - if (!recurrenceChanged) return UPDATE_SCOPE_OPTIONS; - - return UPDATE_SCOPE_OPTIONS.slice(1); - }, [recurrenceChanged]); + const closeDialog = useCallback(() => { + setRecurrenceUpdateScopeDialogOpen(false); + }, [setRecurrenceUpdateScopeDialogOpen]); const onSubmitHandler = useCallback(() => { - onUpdateScopeChange(value); - setValue(RecurringEventUpdateScope.THIS_EVENT); - }, [value, onUpdateScopeChange]); - - if (!isRecurrenceUpdateScopeDialogOpen) return null; + onUpdateScopeChange(activeScope); + setSelectedScope(RecurringEventUpdateScope.THIS_EVENT); + }, [activeScope, onUpdateScopeChange]); return ( - - +
- -
- -
- Apply Changes To + {options.map((option) => { + const isSelected = activeScope === option; - {options.map(([id, option]) => ( -
- setValue(option)} - checked={value === option} - id={`recurring-event-update-scope-select-${id}`} - /> - - -
- ))} -
- - setRecurrenceUpdateScopeDialogOpen(false)} + return ( +
-
-
- - +
+ + + + Cancel + + + Ok + + + ); } From 56b30766c532d6dc9705ab621facc8eb11da5e72 Mon Sep 17 00:00:00 2001 From: Ugur Armagan Date: Sun, 31 May 2026 11:56:39 -0500 Subject: [PATCH 4/5] fix(web): keep event action menu open --- .../Forms/ActionsMenu/ActionsMenu.test.tsx | 148 ++++++++++++++++++ .../views/Forms/ActionsMenu/ActionsMenu.tsx | 2 + .../components/Draft/grid/GridDraft.test.tsx | 80 +++++++--- .../Week/components/Draft/grid/GridDraft.tsx | 6 +- 4 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 packages/web/src/views/Forms/ActionsMenu/ActionsMenu.test.tsx diff --git a/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.test.tsx b/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.test.tsx new file mode 100644 index 000000000..f8fd7d31c --- /dev/null +++ b/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.test.tsx @@ -0,0 +1,148 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { + type HTMLProps, + type PropsWithChildren, + type ReactElement, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { ThemeProvider } from "styled-components"; +import { theme } from "@web/common/styles/theme"; +import { afterEach, describe, expect, it, mock } from "bun:test"; + +let floatingOptions: { onOpenChange?: (open: boolean) => void } | null = null; + +mock.module("@floating-ui/react", () => ({ + autoUpdate: mock(), + flip: mock(() => ({})), + FloatingFocusManager: ({ + children, + closeOnFocusOut, + }: PropsWithChildren<{ closeOnFocusOut?: boolean }>) => ( +
{ + if (closeOnFocusOut === false) return; + if (event.target !== event.currentTarget) { + floatingOptions?.onOpenChange?.(false); + } + }} + > + {children} +
+ ), + FloatingPortal: ({ children }: PropsWithChildren) => <>{children}, + offset: mock(() => ({})), + shift: mock(() => ({})), + useClick: mock(() => ({})), + useDismiss: mock(() => ({})), + useFloating: (options: { onOpenChange?: (open: boolean) => void }) => { + floatingOptions = options; + return { + context: { + floatingStyles: {}, + }, + refs: { + setFloating: mock(), + setReference: mock(), + }, + }; + }, + useInteractions: mock( + ( + interactions: Array<{ + getItemProps?: ( + props?: HTMLProps, + ) => HTMLProps; + }>, + ) => ({ + getFloatingProps: (props = {}) => props, + getItemProps: (props = {}) => + interactions.reduce( + (itemProps, interaction) => + interaction.getItemProps?.(itemProps) ?? itemProps, + props, + ), + getReferenceProps: ( + props: { + onClick?: (event: ReactMouseEvent) => void; + } = {}, + ) => ({ + ...props, + onClick: (event: ReactMouseEvent) => { + props.onClick?.(event); + floatingOptions?.onOpenChange?.(true); + }, + }), + }), + ), + useListNavigation: mock((_context, props) => { + const { focusItemOnHover } = props as { focusItemOnHover?: boolean }; + + return { + getItemProps: (userProps: HTMLProps = {}) => ({ + ...userProps, + onMouseMove: (event: ReactMouseEvent) => { + userProps.onMouseMove?.(event); + + if (focusItemOnHover !== false) { + event.currentTarget.focus(); + } + }, + }), + }; + }), + useRole: mock(() => ({})), +})); + +mock.module("@web/common/hooks/useGridMaxZIndex", () => ({ + useGridMaxZIndex: () => 0, +})); + +const { ActionsMenu, useMenuContext } = + require("./ActionsMenu") as typeof import("./ActionsMenu"); + +const renderWithTheme = (ui: ReactElement) => + render({ui}); + +const TestMenuItem = () => { + const menuContext = useMenuContext(); + const itemProps = menuContext?.getItemProps() ?? {}; + + return ( + + ); +}; + +describe("ActionsMenu", () => { + afterEach(() => { + floatingOptions = null; + }); + + it("keeps mouse hover from stealing focus from the editor action trigger", () => { + renderWithTheme( + {() => }, + ); + + const trigger = screen.getByRole("button", { name: "Open actions menu" }); + trigger.focus(); + + fireEvent.click(trigger); + fireEvent.mouseMove(screen.getByRole("menuitem", { name: "Delete Event" })); + + expect(document.activeElement).toBe(trigger); + }); + + it("keeps the menu mounted when focus moves inside it", () => { + renderWithTheme( + {() => }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Open actions menu" })); + fireEvent.focus(screen.getByRole("menuitem", { name: "Delete Event" })); + + expect( + screen.getByRole("menuitem", { name: "Delete Event" }), + ).toBeVisible(); + }); +}); diff --git a/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.tsx b/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.tsx index 6a91939da..006609807 100644 --- a/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.tsx +++ b/packages/web/src/views/Forms/ActionsMenu/ActionsMenu.tsx @@ -134,6 +134,7 @@ export const ActionsMenu: React.FC = ({ setActiveIndex(sparseIndex); } }, + focusItemOnHover: false, loop: true, }); @@ -179,6 +180,7 @@ export const ActionsMenu: React.FC = ({ diff --git a/packages/web/src/views/Week/components/Draft/grid/GridDraft.test.tsx b/packages/web/src/views/Week/components/Draft/grid/GridDraft.test.tsx index 861d8c17f..dc0e738d8 100644 --- a/packages/web/src/views/Week/components/Draft/grid/GridDraft.test.tsx +++ b/packages/web/src/views/Week/components/Draft/grid/GridDraft.test.tsx @@ -1,6 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { type PropsWithChildren, type Ref } from "react"; +import { type PropsWithChildren, type Ref, useState } from "react"; import { Origin, Priorities } from "@core/constants/core.constants"; import dayjs from "@core/util/date/dayjs"; import { CALENDAR_DECK_MIN_WIDTH } from "@web/common/calendar-grid/calendarGrid.constants"; @@ -11,15 +11,28 @@ import { DraftContext } from "@web/views/Week/components/Draft/context/DraftCont import { type WeekProps } from "@web/views/Week/hooks/useWeek"; import { afterEach, describe, expect, it, mock } from "bun:test"; -let floatingFocusManagerProps: { modal?: boolean } | null = null; - mock.module("@floating-ui/react", () => ({ FloatingFocusManager: ({ children, - ...props - }: PropsWithChildren<{ modal?: boolean }>) => { - floatingFocusManagerProps = props; - return <>{children}; + closeOnFocusOut, + modal, + }: PropsWithChildren<{ closeOnFocusOut?: boolean; modal?: boolean }>) => { + const [isMounted, setIsMounted] = useState(true); + + return ( +
{ + if (closeOnFocusOut === false) return; + if (event.target !== event.currentTarget) { + setIsMounted(false); + } + }} + > + {isMounted ? children : null} +
+ ); }, })); @@ -35,20 +48,25 @@ mock.module("@web/views/Forms/EventForm/EventForm", () => ({ onSubmit?: (event: Schema_GridEvent) => void; titleInputRef?: Ref; }) => ( - { - if (event.key === "Enter") { - event.preventDefault(); - onSubmit?.(draftEvent); - } - - if (event.key === "ArrowDown") { - onDraftTitleArrowKey?.(event.key); - } - }} - ref={titleInputRef} - /> + <> + { + if (event.key === "Enter") { + event.preventDefault(); + onSubmit?.(draftEvent); + } + + if (event.key === "ArrowDown") { + onDraftTitleArrowKey?.(event.key); + } + }} + ref={titleInputRef} + /> + + ), })); @@ -153,7 +171,6 @@ const renderGridDraft = ({ afterEach(() => { document.body.innerHTML = ""; - floatingFocusManagerProps = null; }); describe("GridDraft keyboard focus", () => { @@ -177,10 +194,23 @@ describe("GridDraft keyboard focus", () => { }); }); - it("keeps the floating form non-modal while the draft block is a focus target", () => { + it("keeps the floating form mounted when focus moves into nested menus", async () => { renderGridDraft(); - expect(floatingFocusManagerProps?.modal).toBe(false); + expect(screen.getByTestId("grid-draft-focus-manager")).toHaveAttribute( + "data-modal", + "false", + ); + + fireEvent.focus( + screen.getByRole("button", { name: "Nested action menu item" }), + ); + + await waitFor(() => { + expect( + screen.getByRole("textbox", { name: "Draft title" }), + ).toBeVisible(); + }); }); it("keeps an active overlapping saved draft at its stacked width while raising it", () => { diff --git a/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx b/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx index 41be91f94..7089cc0dd 100644 --- a/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx +++ b/packages/web/src/views/Week/components/Draft/grid/GridDraft.tsx @@ -133,7 +133,11 @@ export const GridDraft: FC = ({ )} {isFormOpen && ( - + Date: Sun, 31 May 2026 15:55:10 -0500 Subject: [PATCH 5/5] fix(web): keep modals above event editors --- packages/web/src/common/constants/web.constants.ts | 1 + packages/web/src/components/OverlayPanel/OverlayPanel.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/src/common/constants/web.constants.ts b/packages/web/src/common/constants/web.constants.ts index fc52a106d..f8d442f4c 100644 --- a/packages/web/src/common/constants/web.constants.ts +++ b/packages/web/src/common/constants/web.constants.ts @@ -48,6 +48,7 @@ export enum ZIndex { export const Z_INDEX_FLOATING_FORM = ZIndex.MAX + ZIndex.LAYER_1; export const Z_INDEX_FLOATING_MENU = Z_INDEX_FLOATING_FORM + 1; +export const Z_INDEX_MODAL = Z_INDEX_FLOATING_MENU + ZIndex.LAYER_1; export const ACCEPTED_TIMES = [ "12:00 AM", diff --git a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx index 05fa1fb8b..4b241791a 100644 --- a/packages/web/src/components/OverlayPanel/OverlayPanel.tsx +++ b/packages/web/src/components/OverlayPanel/OverlayPanel.tsx @@ -6,6 +6,7 @@ import { useId, useRef, } from "react"; +import { Z_INDEX_MODAL } from "@web/common/constants/web.constants"; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -54,7 +55,7 @@ export const OverlayPanel = ({ }, [role]); const backdropClasses = clsx( - "fixed inset-0 z-20 flex items-center justify-center bg-bg-primary/85 backdrop-blur-sm", + "fixed inset-0 flex items-center justify-center bg-bg-primary/85 backdrop-blur-sm", ); const panelClasses = clsx("flex flex-col items-center", { @@ -107,6 +108,7 @@ export const OverlayPanel = ({ onClick={handleBackdropClick} onKeyDown={handleKeyDown} role="presentation" + style={{ zIndex: Z_INDEX_MODAL }} tabIndex={-1} > {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-modal is only set when the panel role is dialog. */}