From db71f26dcc9b58f461a7cc9a6312327303cadbc7 Mon Sep 17 00:00:00 2001 From: isletspace Date: Tue, 8 Sep 2026 14:04:11 +0800 Subject: [PATCH 1/3] fix: replace native caption selects in calendar with portal-rendered menu (PLANE-13) The month/year caption dropdowns rendered react-day-picker's default invisible native ` over the caption label. + * Inside Plane's portaled dropdown popups, clicking that native select opens an OS-level + * popup window: the browser window blurs and the surrounding Headless UI combobox treats it + * as an outside interaction, tearing down the calendar before the selection lands. Rendering + * an in-page menu instead (portal-positioned, so it is not clipped by the popup's + * `overflow-hidden`) keeps the interaction inside the window and avoids the blur entirely. + */ +function CalendarCaptionDropdown(dropdownProps: DropdownProps) { + const { options, value, onChange, disabled, className, classNames, "aria-label": ariaLabel, style } = dropdownProps; + const [isOpen, setIsOpen] = React.useState(false); + const [menuPosition, setMenuPosition] = React.useState<{ top: number; left: number; openUpwards: boolean } | null>( + null + ); + const triggerRef = React.useRef(null); + const menuRef = React.useRef(null); + + const selectedOption = options?.find((option) => option.value === Number(value)); + + const handleTriggerClick = () => { + if (isOpen) { + setIsOpen(false); + return; + } + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + // flip the menu above the trigger when there is not enough room below + const openUpwards = rect.bottom + 260 > window.innerHeight && rect.top > 260; + setMenuPosition({ top: openUpwards ? rect.top - 4 : rect.bottom + 4, left: rect.left, openUpwards }); + setIsOpen(true); + }; + + React.useEffect(() => { + if (!isOpen) return; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return; + setIsOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsOpen(false); + }; + // the menu is anchored to viewport coordinates: any viewport change closes it + const handleViewportChange = () => setIsOpen(false); + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [isOpen]); + + const handleOptionSelect = (optionValue: number) => { + // react-day-picker's month/year change handlers read `e.target.value` from the change event + onChange?.({ target: { value: String(optionValue) } } as React.ChangeEvent); + setIsOpen(false); + }; + + return ( + + + {isOpen && + menuPosition && + createPortal( +
+ {options?.map((option) => ( + + ))} +
, + document.body + )} +
+ ); +} + export function Calendar({ className, showOutsideDays = true, ...props }: CalendarProps) { const currentYear = new Date().getFullYear(); const thirtyYearsAgoFirstDay = new Date(currentYear - 30, 0, 1); @@ -23,16 +144,17 @@ export function Calendar({ className, showOutsideDays = true, ...props }: Calend className={cn("p-3", className)} weekStartsOn={props.weekStartsOn} components={{ - Chevron: ({ className, ...props }) => ( + Chevron: ({ className: chevronClassName, ...chevronProps }) => ( ), + Dropdown: CalendarCaptionDropdown, }} startMonth={thirtyYearsAgoFirstDay} endMonth={thirtyYearsFromNowFirstDay} From 8429138d1a1eeef65e919b026aab78065856bc81 Mon Sep 17 00:00:00 2001 From: isletspace Date: Tue, 8 Sep 2026 14:29:24 +0800 Subject: [PATCH 2/3] fix(propel): keep calendar caption menu open when wheel-scrolling it, scroll selected option into view (PLANE-13) The window-level capture scroll listener closed the menu on any scroll, including scrolling the menu's own option list. Ignore scrolls whose target is inside the menu. Also scrollIntoView the selected option on open so long year lists start at the current year. --- packages/propel/src/calendar/root.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/propel/src/calendar/root.tsx b/packages/propel/src/calendar/root.tsx index 1ca2ed68467..a19771f5d38 100644 --- a/packages/propel/src/calendar/root.tsx +++ b/packages/propel/src/calendar/root.tsx @@ -60,8 +60,12 @@ function CalendarCaptionDropdown(dropdownProps: DropdownProps) { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setIsOpen(false); }; - // the menu is anchored to viewport coordinates: any viewport change closes it - const handleViewportChange = () => setIsOpen(false); + // the menu is anchored to viewport coordinates: any viewport change closes it, + // except scrolls inside the menu itself (wheel-scrolling the option list) + const handleViewportChange = (event: Event) => { + if (event.target instanceof Node && menuRef.current?.contains(event.target)) return; + setIsOpen(false); + }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", handleViewportChange); @@ -80,6 +84,12 @@ function CalendarCaptionDropdown(dropdownProps: DropdownProps) { setIsOpen(false); }; + // bring the selected option into view when the menu opens (e.g. the current year) + React.useEffect(() => { + if (!isOpen) return; + menuRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "center" }); + }, [isOpen]); + return (