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
Expand Up @@ -126,6 +126,7 @@ const CalendarAllDayEventCardBase = (
}

e.preventDefault();
e.stopPropagation();
if (isPending) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<div onKeyDown={onParentKeyDown}>
<CalendarTimedEventCard
displayMode="saved"
event={createEvent()}
motionMode="idle"
onEventKeyDown={onEventKeyDown}
position={position}
/>
</div>,
);

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();
Expand Down Expand Up @@ -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.
<div onKeyDown={onParentKeyDown}>
<CalendarAllDayEventCard
event={createEvent({
isAllDay: true,
title: "Conference",
})}
isPlaceholder={false}
onEventKeyDown={onEventKeyDown}
position={position}
/>
</div>,
);

fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });

expect(onEventKeyDown).toHaveBeenCalledTimes(1);
expect(onParentKeyDown).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ const CalendarTimedEventCardBase = (
}

e.preventDefault();
e.stopPropagation();
if (isPending || !onEventKeyDown) {
return;
}
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/common/constants/web.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { OverlayPanel } from "@web/components/OverlayPanel/OverlayPanel";
import {
OverlayPanel,
OverlayPanelActionButton,
OverlayPanelActions,
} from "@web/components/OverlayPanel/OverlayPanel";

interface LogoutConfirmationDialogProps {
isOpen: boolean;
Expand All @@ -20,22 +24,14 @@ export function LogoutConfirmationDialog({
onDismiss={onCancel}
variant="modal"
>
<div className="flex w-full justify-end gap-3">
<button
className="h-11 rounded border border-border-primary bg-panel-badge-bg px-4 text-sm text-text-lighter transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg disabled:pointer-events-none disabled:opacity-50"
onClick={onCancel}
type="button"
>
<OverlayPanelActions>
<OverlayPanelActionButton onClick={onCancel}>
Cancel
</button>
<button
className="h-11 rounded bg-accent-primary px-4 text-sm text-text-dark transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg disabled:pointer-events-none disabled:opacity-50"
onClick={onConfirm}
type="button"
>
</OverlayPanelActionButton>
<OverlayPanelActionButton variant="primary" onClick={onConfirm}>
Log out
</button>
</div>
</OverlayPanelActionButton>
</OverlayPanelActions>
</OverlayPanel>
);
}
47 changes: 45 additions & 2 deletions packages/web/src/components/OverlayPanel/OverlayPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import clsx from "clsx";
import { type ReactNode, useEffect, useId, useRef } from "react";
import {
type ButtonHTMLAttributes,
type ReactNode,
useEffect,
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"])';
Expand Down Expand Up @@ -48,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", {
Expand Down Expand Up @@ -101,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. */}
Expand Down Expand Up @@ -140,3 +148,38 @@ export const OverlayPanel = ({
</div>
);
};

interface OverlayPanelActionsProps {
children: ReactNode;
}

export const OverlayPanelActions = ({ children }: OverlayPanelActionsProps) => (
<div className="flex w-full justify-end gap-3">{children}</div>
);

interface OverlayPanelActionButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary";
}

export const OverlayPanelActionButton = ({
children,
className,
type = "button",
variant = "secondary",
...buttonProps
}: OverlayPanelActionButtonProps) => (
<button
className={clsx(
"h-11 rounded px-4 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg disabled:pointer-events-none disabled:opacity-50",
variant === "primary"
? "bg-accent-primary text-text-dark transition hover:brightness-110"
: "border border-border-primary bg-panel-badge-bg text-text-lighter transition-colors hover:bg-panel-bg",
className,
)}
type={type}
{...buttonProps}
>
{children}
</button>
);
148 changes: 148 additions & 0 deletions packages/web/src/views/Forms/ActionsMenu/ActionsMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }>) => (
<div
onFocusCapture={(event) => {
if (closeOnFocusOut === false) return;
if (event.target !== event.currentTarget) {
floatingOptions?.onOpenChange?.(false);
}
}}
>
{children}
</div>
),
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<HTMLElement>,
) => HTMLProps<HTMLElement>;
}>,
) => ({
getFloatingProps: (props = {}) => props,
getItemProps: (props = {}) =>
interactions.reduce(
(itemProps, interaction) =>
interaction.getItemProps?.(itemProps) ?? itemProps,
props,
),
getReferenceProps: (
props: {
onClick?: (event: ReactMouseEvent<HTMLDivElement>) => void;
} = {},
) => ({
...props,
onClick: (event: ReactMouseEvent<HTMLDivElement>) => {
props.onClick?.(event);
floatingOptions?.onOpenChange?.(true);
},
}),
}),
),
useListNavigation: mock((_context, props) => {
const { focusItemOnHover } = props as { focusItemOnHover?: boolean };

return {
getItemProps: (userProps: HTMLProps<HTMLElement> = {}) => ({
...userProps,
onMouseMove: (event: ReactMouseEvent<HTMLElement>) => {
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(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);

const TestMenuItem = () => {
const menuContext = useMenuContext();
const itemProps = menuContext?.getItemProps() ?? {};

return (
<button type="button" role="menuitem" {...itemProps}>
Delete Event
</button>
);
};

describe("ActionsMenu", () => {
afterEach(() => {
floatingOptions = null;
});

it("keeps mouse hover from stealing focus from the editor action trigger", () => {
renderWithTheme(
<ActionsMenu bgColor="#fff">{() => <TestMenuItem />}</ActionsMenu>,
);

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(
<ActionsMenu bgColor="#fff">{() => <TestMenuItem />}</ActionsMenu>,
);

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();
});
});
2 changes: 2 additions & 0 deletions packages/web/src/views/Forms/ActionsMenu/ActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export const ActionsMenu: React.FC<ActionsMenuProps> = ({
setActiveIndex(sparseIndex);
}
},
focusItemOnHover: false,
loop: true,
});

Expand Down Expand Up @@ -179,6 +180,7 @@ export const ActionsMenu: React.FC<ActionsMenuProps> = ({
<FloatingFocusManager
context={context}
modal={false}
closeOnFocusOut={false}
initialFocus={openedByMouseRef.current ? -1 : 0}
returnFocus={false}
>
Expand Down
Loading