From 8940f45baabfc3482781024d2a76c575e0bc346f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:45:48 +0100 Subject: [PATCH] fix: apply a custom calendar color picked in the system color dialog #1366 --- .../__tests__/calendar-color-menu.test.tsx | 70 +++++++++++++++++++ .../settings/calendar-management-settings.tsx | 21 +++++- components/ui/context-menu.tsx | 5 ++ hooks/use-context-menu.ts | 3 + 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 components/calendar/__tests__/calendar-color-menu.test.tsx diff --git a/components/calendar/__tests__/calendar-color-menu.test.tsx b/components/calendar/__tests__/calendar-color-menu.test.tsx new file mode 100644 index 000000000..bba26e010 --- /dev/null +++ b/components/calendar/__tests__/calendar-color-menu.test.tsx @@ -0,0 +1,70 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; +import { CalendarColorPicker } from '@/components/settings/calendar-management-settings'; + +// The sidebar's "Change color" submenu, as calendar-sidebar-panel builds it. +// "+" opens the system color dialog. It takes the focus away from the +// window, and the menu closed on that blur, removing the input before the +// user clicked OK: the chosen color was lost (#1366). +function ColorMenu({ onColor }: { onColor: (color: string) => void }) { + const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + return ( + <> + + + + { onColor(c); closeContextMenu(); }} allowCustom /> + + + + ); +} + +async function openColorDialog(onColor = vi.fn()) { + render(); + fireEvent.contextMenu(screen.getByText('Work')); + // The menu listens for outside clicks and blur from the next tick on. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + fireEvent.mouseEnter(screen.getByTestId('color').parentElement!); + const input = screen.getByTitle('Custom color').querySelector('input[type="color"]') as HTMLInputElement; + input.focus(); + return { input, onColor }; +} + +describe('the calendar color menu with the system color dialog (#1366)', () => { + it('stays open while the dialog has the focus and applies the color picked there', async () => { + const { input, onColor } = await openColorDialog(); + + act(() => { window.dispatchEvent(new Event('blur')); }); + fireEvent.mouseLeave(screen.getByTestId('color').parentElement!); + await act(() => new Promise((resolve) => setTimeout(resolve, 200))); + expect(input.isConnected).toBe(true); + + fireEvent.change(input, { target: { value: '#b36201' } }); + expect(onColor).toHaveBeenCalledWith('#b36201'); + expect(screen.queryByTestId('color')).toBeNull(); + }); + + it('applies the color once the dialog confirms it, not at every step', async () => { + const { input, onColor } = await openColorDialog(); + + fireEvent.input(input, { target: { value: '#111111' } }); + expect(onColor).not.toHaveBeenCalled(); + expect(input.isConnected).toBe(true); + + fireEvent.change(input, { target: { value: '#222222' } }); + expect(onColor).toHaveBeenCalledTimes(1); + expect(onColor).toHaveBeenCalledWith('#222222'); + }); + + it('still closes when the window loses the focus from elsewhere', async () => { + const { input } = await openColorDialog(); + input.blur(); + + act(() => { window.dispatchEvent(new Event('blur')); }); + + expect(screen.queryByTestId('color')).toBeNull(); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 487e4dc2d..bebf20113 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -36,6 +36,23 @@ function CalendarColorPicker({ allowCustom?: boolean; }) { const selectedIsPreset = CALENDAR_COLORS.includes(value); + const customRef = useRef(null); + const onChangeRef = useRef(onChange); + useEffect(() => { onChangeRef.current = onChange; }); + // Take the custom color when the system dialog confirms it (`change`), not + // at every step (`input`, React's onChange): the first step closed the + // calendar menu around the picker and saved a color the user passed by + // (#1366). + useEffect(() => { + const input = customRef.current; + if (!input) return; + const commit = () => onChangeRef.current(input.value); + input.addEventListener('change', commit); + return () => input.removeEventListener('change', commit); + }, [allowCustom]); + useEffect(() => { + if (customRef.current) customRef.current.value = value || "#3b82f6"; + }, [value]); return (
@@ -62,9 +79,9 @@ function CalendarColorPicker({ title="Custom color" > onChange(e.target.value)} + defaultValue={value || "#3b82f6"} className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" /> {(selectedIsPreset || !value) && ( diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index a9c5ab387..79cd0be79 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -203,6 +203,7 @@ export function ContextMenuSubMenu({ }: ContextMenuSubMenuProps) { const [isOpen, setIsOpen] = useState(false); const closeTimerRef = useRef | null>(null); + const rootRef = useRef(null); useEffect(() => { return () => clearTimeout(closeTimerRef.current ?? undefined); @@ -219,12 +220,16 @@ export function ContextMenuSubMenu({ const handleMouseLeave = () => { closeTimerRef.current = setTimeout(() => { + // A control in the submenu that has the focus may have opened a system + // dialog (a color input): closing would drop its answer (#1366). + if (rootRef.current?.contains(document.activeElement)) return; setIsOpen(false); }, 150); }; return (
(): UseContextMenuReturn { }; const handleBlur = () => { + // A focused control in the menu may have opened a system dialog that + // took the focus from the window, such as a color input's (#1366). + if (menuRef.current?.contains(document.activeElement)) return; closeContextMenu(); };