diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts index d625cc14..db1bce26 100644 --- a/packages/core/src/dom/ui/popover/popover.ts +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -35,6 +35,8 @@ export interface PopoverTriggerProps { export interface PopoverPopupProps { onPointerEnter: (event: UIPointerEvent) => void; onPointerLeave: (event: UIPointerEvent) => void; + onGotPointerCapture: (event: UIPointerEvent) => void; + onLostPointerCapture: (event: UIPointerEvent) => void; onFocusOut: (event: UIFocusEvent) => void; } @@ -56,6 +58,7 @@ export function createPopover(options: PopoverOptions): PopoverApi { let triggerEl: HTMLElement | null = null; let popupEl: HTMLElement | null = null; let hoverTimeout: ReturnType | null = null; + const capturedPointers = new Set(); const layer = createDismissLayer({ transition: options.transition, @@ -154,6 +157,7 @@ export function createPopover(options: PopoverOptions): PopoverApi { // Cleanup hover timeout on destroy. layer.signal.addEventListener('abort', () => { clearHoverTimeout(); + capturedPointers.clear(); triggerEl = null; popupEl = null; }); @@ -227,6 +231,10 @@ export function createPopover(options: PopoverOptions): PopoverApi { onPointerLeave(_event) { if (!options.openOnHover?.()) return; + // A descendant has pointer capture (e.g. slider drag). The leave is + // synthetic — the pointer hasn't actually left — so don't close. + if (capturedPointers.size > 0) return; + clearHoverTimeout(); if (!state.current.active) return; @@ -235,6 +243,14 @@ export function createPopover(options: PopoverOptions): PopoverApi { hoverTimeout = setTimeout(() => applyClose('hover'), closeDelay); }, + onGotPointerCapture(event) { + capturedPointers.add(event.pointerId); + }, + + onLostPointerCapture(event) { + capturedPointers.delete(event.pointerId); + }, + onFocusOut(event) { const relatedTarget = event.relatedTarget as Node | null; diff --git a/packages/core/src/dom/ui/slider.ts b/packages/core/src/dom/ui/slider.ts index 29c97aa5..01383851 100644 --- a/packages/core/src/dom/ui/slider.ts +++ b/packages/core/src/dom/ui/slider.ts @@ -131,6 +131,12 @@ export function createSlider(options: SliderOptions): SliderApi { onPointerDown(event) { if (options.isDisabled()) return; + // Prevent the browser's default mousedown focus behavior. Without this, + // clicking a non-focusable child (e.g. the track) causes the browser to + // move focus away from the thumb after our programmatic `focus()` call, + // which can trigger unrelated `focusout` handlers (e.g. popover close). + event.preventDefault(); + const el = options.getElement(); cachedRect = el.getBoundingClientRect(); diff --git a/packages/core/src/dom/ui/tests/slider.test.ts b/packages/core/src/dom/ui/tests/slider.test.ts index 7edfcbac..a2ee79b2 100644 --- a/packages/core/src/dom/ui/tests/slider.test.ts +++ b/packages/core/src/dom/ui/tests/slider.test.ts @@ -178,6 +178,28 @@ describe('createSlider', () => { slider.destroy(); }); + it('calls preventDefault to suppress default focus behavior', () => { + const slider = createSlider(createOptions()); + + const event = pointerEvent(); + slider.rootProps.onPointerDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + + slider.destroy(); + }); + + it('does not call preventDefault when disabled', () => { + const slider = createSlider(createOptions({ isDisabled: () => true })); + + const event = pointerEvent(); + slider.rootProps.onPointerDown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + + slider.destroy(); + }); + it('does nothing when disabled', () => { const onValueChange = vi.fn(); const slider = createSlider(createOptions({ isDisabled: () => true, onValueChange }));