From 6047f850119d3e3d6e7fc676585fd1f5f2cc64c5 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 13 Aug 2026 20:31:17 -0700 Subject: [PATCH 1/4] fix(core): stop popup triggers from fighting their own light dismiss (#5004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser light dismiss and the trigger's own click come from one press: the popover is dismissed on pointerup and the click follows a beat later. Which one React sees first is a race that varies by engine and by load, and losing it means the click reads a popup that is already closed and reopens it — the button appears not to close the menu at all. Two components carried their own 50ms timing guard against this; the rest carried nothing. The layer primitive now answers the question directly: wasJustDismissed() compares the gesture that dismissed the layer with the gesture in flight, so a click from that same press is absorbed no matter how long the main thread was blocked in between, while a deliberate second press is always a new gesture. Both hand-rolled copies collapse into it. The same light dismiss also fires for controls that live ON the trigger — the clear and status buttons — which made them unusable while the popup they belong to was open. keepOpenProps names such a control an invoker of the popover for the duration of the press, which is what stops the dismissal; the attribute comes off afterwards so the button does not report itself as expanded to assistive tech. --- .changeset/layer-dismiss-gesture.md | 7 + .../ComplexSelector/ComplexSelector.test.tsx | 34 +++- .../core/src/DropdownMenu/DropdownMenu.tsx | 13 +- packages/core/src/Field/InputClearButton.tsx | 11 ++ packages/core/src/Layer/gestureCounter.ts | 45 +++++ packages/core/src/Layer/useLayer.test.tsx | 156 +++++++++++++++++- packages/core/src/Layer/useLayer.tsx | 115 +++++++++++++ .../src/MultiSelector/MultiSelector.test.tsx | 36 ++++ .../core/src/MultiSelector/MultiSelector.tsx | 3 + packages/core/src/MultiSelector/hooks.ts | 11 +- packages/core/src/Popover/Popover.tsx | 10 +- packages/core/src/Popover/usePopover.tsx | 27 ++- packages/core/src/Selector/Selector.test.tsx | 37 +++++ packages/core/src/Selector/Selector.tsx | 3 + packages/core/src/Selector/hooks.ts | 19 ++- 15 files changed, 497 insertions(+), 30 deletions(-) create mode 100644 .changeset/layer-dismiss-gesture.md create mode 100644 packages/core/src/Layer/gestureCounter.ts diff --git a/.changeset/layer-dismiss-gesture.md b/.changeset/layer-dismiss-gesture.md new file mode 100644 index 0000000000000..eb0fdda9f2ca8 --- /dev/null +++ b/.changeset/layer-dismiss-gesture.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Popup triggers no longer fight the browser's own light dismiss: pressing the button of an open Selector, MultiSelector, ComplexSelector, DropdownMenu or Popover closes it once instead of closing and reopening, and a clear or status button sitting on the trigger no longer dismisses the popup it belongs to + +@cixzhang diff --git a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx index 57bd7e49169c2..380d0575bdf5f 100644 --- a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx +++ b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx @@ -10,7 +10,7 @@ */ import {describe, expect, it, vi} from 'vitest'; -import {render, screen, waitFor} from '@testing-library/react'; +import {render, screen, waitFor, act, fireEvent} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {ComplexSelector} from './ComplexSelector'; @@ -226,4 +226,36 @@ describe('ComplexSelector popup theme target', () => { document.querySelector('.astryx-complex-selector-popup'), ).not.toBeNull(); }); + + it('stays closed when the trigger click follows its own light dismiss (#5004)', async () => { + const user = userEvent.setup(); + render( + + {() => } + , + ); + const trigger = screen.getByRole('button', {name: 'Fruit blend'}); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + // The browser dismissed the popup on pointerup and queued the toggle. When + // that event lands before the click — WebKit, or any engine under load — + // the click used to read a closed popup and reopen it. + const popover = document.querySelector('[popover]') as HTMLElement; + // Back to back: the guard window is the length of one gesture, and in a + // browser the click lands a few milliseconds behind the dismissal. + act(() => { + popover.dispatchEvent( + Object.assign(new Event('toggle'), { + oldState: 'open', + newState: 'closed', + }), + ); + }); + // Synchronously, so the click still falls inside the one gesture the guard + // covers — in a browser it lands a few milliseconds behind the dismissal. + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/packages/core/src/DropdownMenu/DropdownMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenu.tsx index 78600c22d563e..4a54260c6b862 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx @@ -261,14 +261,8 @@ export function DropdownMenu({ const isControlled = controlledIsOpen !== undefined; const isOpen = isControlled ? controlledIsOpen : internalIsOpen; - // Track when the menu was last hidden so a near-simultaneous trigger - // click — e.g. on iOS Safari where pointerdown fires light-dismiss - // before the trigger's click event — can't immediately re-open it. - const lastHideTimeRef = useRef(0); - // Close menu + return focus to trigger const handleLayerHide = useCallback(() => { - lastHideTimeRef.current = Date.now(); onOpenChange?.(false); if (!isControlled) { setInternalIsOpen(false); @@ -422,11 +416,8 @@ export function DropdownMenu({ const handleButtonClick = useCallback( (e: React.MouseEvent) => { - // If the menu was just closed by light dismiss (e.g. iOS Safari fires - // pointerdown → hide before the trigger's click), the click would - // otherwise immediately re-open it. Short-circuit within the guard - // window. - if (Date.now() - lastHideTimeRef.current < 50) { + // The click that light-dismissed the menu is not a request to reopen it. + if (popover.wasJustDismissed()) { return; } onClick?.(); diff --git a/packages/core/src/Field/InputClearButton.tsx b/packages/core/src/Field/InputClearButton.tsx index f0ad17fddba93..12596085527af 100644 --- a/packages/core/src/Field/InputClearButton.tsx +++ b/packages/core/src/Field/InputClearButton.tsx @@ -30,6 +30,13 @@ const styles = stylex.create({ export interface InputClearButtonProps { label: string; onClick: (e: React.MouseEvent) => void; + /** + * Pointer and capture-phase click handlers, for inputs that render this + * button beside an open layer: spread `keepOpenProps` so pressing the clear + * button does not light-dismiss the layer it sits next to. + */ + onPointerDown?: React.PointerEventHandler; + onClickCapture?: React.MouseEventHandler; xstyle?: stylex.StyleXStyles; /** * Extra class(es) for the clear glyph itself, merged onto the shared @@ -44,6 +51,8 @@ export interface InputClearButtonProps { export function InputClearButton({ label, onClick, + onPointerDown, + onClickCapture, xstyle, iconClassName, }: InputClearButtonProps): ReactNode { @@ -66,6 +75,8 @@ export function InputClearButton({ /> } onClick={onClick} + onPointerDown={onPointerDown} + onClickCapture={onClickCapture} isIconOnly xstyle={[styles.button, xstyle]} /> diff --git a/packages/core/src/Layer/gestureCounter.ts b/packages/core/src/Layer/gestureCounter.ts new file mode 100644 index 0000000000000..7fa77ef99e5dc --- /dev/null +++ b/packages/core/src/Layer/gestureCounter.ts @@ -0,0 +1,45 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file gestureCounter.ts + * @input Listens for pointerdown and keydown on the document + * @output Exports currentGesture, a counter identifying the user gesture in + * flight + * @position Internal to Layer; used by useLayer to tell a click that belongs + * to a dismissing press from a fresh one + * + * A browser light-dismiss and the trigger's own click come from ONE press, and + * which of them React sees first is a race. Comparing timestamps against a + * window guesses; counting gestures does not. The counter advances on every + * new press or keystroke, so "the click from the gesture that dismissed the + * layer" is exactly "the click while the counter still reads what it read at + * the dismissal", no matter how long the main thread was blocked in between. + */ + +let gesture = 0; +let isListening = false; + +function advance() { + gesture += 1; +} + +function listen() { + if (isListening || typeof document === 'undefined') { + return; + } + isListening = true; + // Capture phase: the count must advance before any handler reads it. + document.addEventListener('pointerdown', advance, true); + document.addEventListener('keydown', advance, true); +} + +/** + * Identifies the user gesture in flight. Two reads returning the same value + * happened within one press (or one keystroke). + */ +export function currentGesture(): number { + listen(); + return gesture; +} diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index 4700f7cb60ceb..74db28855b060 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -10,7 +10,7 @@ */ import {describe, it, expect, vi, afterEach} from 'vitest'; -import {render, act} from '@testing-library/react'; +import {render, act, fireEvent} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {useLayer, getPositionTryFallbacks} from './useLayer'; import type { @@ -485,9 +485,9 @@ describe('useLayer context positioning', () => { const NONE = {blockStart: '', blockEnd: '', inlineStart: '', inlineEnd: ''}; it('is flush by default', async () => { - expect(await openAndGetOffsets()).toEqual( - NONE, - ); + expect( + await openAndGetOffsets(), + ).toEqual(NONE); }); // Both edges of the axis, so the gap survives a position-try-fallbacks @@ -548,3 +548,151 @@ describe('useLayer context positioning', () => { expect(style).not.toContain('position-anchor'); }); }); + +describe('keepOpenProps (controls on the trigger, #5004)', () => { + function ClearableTriggerHarness() { + const layer = useLayer({mode: 'context'}); + return ( + <> + + + {layer.render(Layer content, {placement: 'below'})} + + ); + } + + it('names the control an invoker for the duration of the press', async () => { + const user = userEvent.setup(); + const {container, getByRole} = render(); + const clear = getByRole('button', {name: 'Clear'}); + + await user.click(getByRole('button', {name: 'Trigger'})); + const popover = container.querySelector('[popover]') as HTMLElement; + + await user.pointer({keys: '[MouseLeft>]', target: clear}); + expect(clear.getAttribute('popovertarget')).toBe(popover.id); + + await user.pointer({keys: '[/MouseLeft]', target: clear}); + expect(clear).not.toHaveAttribute('popovertarget'); + }); + + it('leaves the control alone while the layer is closed', async () => { + const user = userEvent.setup(); + const {getByRole} = render(); + const clear = getByRole('button', {name: 'Clear'}); + + await user.click(clear); + + expect(clear).not.toHaveAttribute('popovertarget'); + }); + + it("cancels the invoker's own toggle so the press cannot close the layer", async () => { + const user = userEvent.setup(); + const {container, getByRole} = render(); + await user.click(getByRole('button', {name: 'Trigger'})); + const popover = container.querySelector('[popover]') as HTMLElement; + const clear = getByRole('button', {name: 'Clear'}); + + const click = new MouseEvent('click', {bubbles: true, cancelable: true}); + await act(async () => { + clear.setAttribute('popovertarget', popover.id); + clear.dispatchEvent(click); + }); + + expect(click.defaultPrevented).toBe(true); + expect(clear).not.toHaveAttribute('popovertarget'); + }); +}); + +describe('wasJustDismissed (light dismiss vs. the trigger click, #5004)', () => { + function GuardedTriggerHarness() { + const layer = useLayer({mode: 'context'}); + return ( + <> + + {layer.isOpen ? 'open' : 'closed'} + {layer.render(Layer content, {placement: 'below'})} + + ); + } + + /** + * The browser closing the layer itself: it hides the element and queues a + * `toggle` event, which is what reaches React — before the click, on the + * engines that lose the race. + */ + function lightDismiss(container: HTMLElement) { + const popover = container.querySelector('[popover]') as HTMLElement; + act(() => { + popover.dispatchEvent( + Object.assign(new Event('toggle'), { + oldState: 'open', + newState: 'closed', + }), + ); + }); + } + + it('absorbs the trigger click that follows a light dismiss', async () => { + const user = userEvent.setup(); + const {container, getByRole, getByTestId} = render( + , + ); + const trigger = getByRole('button', {name: 'Trigger'}); + + await user.click(trigger); + expect(getByTestId('state')).toHaveTextContent('open'); + + // Dismissal and click within one press: no pointerdown in between. + lightDismiss(container); + fireEvent.click(trigger); + + expect(getByTestId('state')).toHaveTextContent('closed'); + }); + + it('acts on a deliberate second press', async () => { + const user = userEvent.setup(); + const {container, getByRole, getByTestId} = render( + , + ); + const trigger = getByRole('button', {name: 'Trigger'}); + + await user.click(trigger); + lightDismiss(container); + // A press of its own — a new gesture, however soon it lands. + await user.click(trigger); + + expect(getByTestId('state')).toHaveTextContent('open'); + }); + + it('leaves a programmatic hide unguarded', async () => { + const user = userEvent.setup(); + const {getByRole, getByTestId} = render(); + const trigger = getByRole('button', {name: 'Trigger'}); + + // Three presses with no browser-initiated close between them. + await user.click(trigger); + await user.click(trigger); + await user.click(trigger); + + expect(getByTestId('state')).toHaveTextContent('open'); + }); +}); diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 5e4e1744b1fd5..5dab27d51c0d4 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -18,6 +18,7 @@ import React, { useCallback, useEffect, useId, + useMemo, useRef, useState, type ReactNode, @@ -26,6 +27,7 @@ import React, { import * as stylex from '@stylexjs/stylex'; import type {StyleXStyles} from '@stylexjs/stylex'; import {addAnchorName, removeAnchorName} from './anchorName'; +import {currentGesture} from './gestureCounter'; import {typographyVars} from '../theme/tokens.stylex'; const styles = stylex.create({ @@ -64,6 +66,18 @@ const styles = stylex.create({ }), }); +/** + * Props for a control that sits on the trigger but must not dismiss the layer. + */ +export interface KeepLayerOpenProps { + onPointerDown: React.PointerEventHandler; + /** + * Capture phase, so spreading these props never collides with the control's + * own `onClick`. + */ + onClickCapture: React.MouseEventHandler; +} + /** * Position placement relative to anchor. * Logical: start/end resolve against the popover's own inherited direction @@ -262,6 +276,31 @@ export interface ContextLayerReturn { */ isOpen: boolean; + /** + * Props for a control that lives on the trigger — a clear button, a status + * button — and must not dismiss this layer when pressed. + * + * Such a control sits OUTSIDE the popover, so the browser light-dismisses + * the layer as soon as it is pressed and the affordance is unusable while + * the layer it belongs to is open. Spreading these props names the control + * an invoker of this popover, which puts it inside the layer for that + * decision. Merge them with the control's own handlers. + */ + keepOpenProps: KeepLayerOpenProps; + + /** + * Whether the browser itself closed this layer — light dismiss, or popover + * stack eviction — during the gesture still in flight, rather than a call + * to `hide()`. + * + * A trigger checks this before acting on a click: a click from that same + * press is the tail of the dismissal, and toggling on it would reopen what + * the user just closed. The browser dismisses on pointerup and the click + * follows a beat later, so which one React sees first is a race that varies + * by engine and by load — this does not depend on winning it. + */ + wasJustDismissed: () => boolean; + /** * Unique ID for aria-describedby */ @@ -298,6 +337,28 @@ export interface FixedLayerReturn { */ isOpen: boolean; + /** + * Props for a control that lives on the trigger — a clear button, a status + * button — and must not dismiss this layer when pressed. + * + * Such a control sits OUTSIDE the popover, so the browser light-dismisses + * the layer as soon as it is pressed and the affordance is unusable while + * the layer it belongs to is open. Spreading these props names the control + * an invoker of this popover, which puts it inside the layer for that + * decision. Merge them with the control's own handlers. + */ + keepOpenProps: KeepLayerOpenProps; + + /** + * Whether the browser itself just closed this layer — light dismiss or + * popover stack eviction — rather than a call to `hide()`. + * + * A trigger checks this before acting on a click: within the guard window + * the click belongs to the gesture that dismissed the layer, so toggling on + * it would reopen what the user just closed. + */ + wasJustDismissed: () => boolean; + /** * Unique ID for aria-describedby */ @@ -418,6 +479,11 @@ export function useLayer( // stale-closure reads of the previous isOpen value. const isOpenRef = useRef(false); + // The gesture during which the browser last closed this layer on its own. + // Read through wasJustDismissed by triggers deciding whether a click is + // theirs to act on. + const dismissedByGestureRef = useRef(null); + const show = useCallback(() => { const popover = popoverRef.current; if (popover && !isOpenRef.current) { @@ -456,6 +522,50 @@ export function useLayer( } }, [onHide]); + const wasJustDismissed = useCallback( + () => + dismissedByGestureRef.current !== null && + dismissedByGestureRef.current === currentGesture(), + [], + ); + + const keepOpenProps: KeepLayerOpenProps = useMemo( + () => ({ + onPointerDown: (event: React.PointerEvent) => { + if (!isOpenRef.current) { + return; + } + // The browser reads the invoker relationship twice — once when the + // press starts and once when it ends — and only then decides to + // light-dismiss, so the attribute has to span the whole gesture. It + // comes off afterwards because a permanent invoker reports itself as + // `expanded` to assistive tech, which a clear button is not. + const control = event.currentTarget; + control.setAttribute('popovertarget', id); + document.addEventListener( + 'pointerup', + () => { + // A task, not a microtask: the dismissal runs as the pointerup + // default action, after this listener. + window.setTimeout(() => { + control.removeAttribute('popovertarget'); + }, 0); + }, + {once: true}, + ); + }, + onClickCapture: (event: React.MouseEvent) => { + const control = event.currentTarget; + if (control.hasAttribute('popovertarget')) { + // Being an invoker would otherwise toggle the layer shut. + event.preventDefault(); + control.removeAttribute('popovertarget'); + } + }, + }), + [id], + ); + // Ref for trigger element (context mode only) const ref: RefCallback | undefined = mode === 'context' @@ -489,6 +599,7 @@ export function useLayer( const toggleEvent = e as ToggleEvent; if (toggleEvent.newState === 'closed' && isOpenRef.current) { isOpenRef.current = false; + dismissedByGestureRef.current = currentGesture(); setIsOpen(false); onHide?.(); } @@ -662,6 +773,8 @@ export function useLayer( show, hide, isOpen, + keepOpenProps, + wasJustDismissed, id, render: renderContext, }; @@ -672,6 +785,8 @@ export function useLayer( show, hide, isOpen, + keepOpenProps, + wasJustDismissed, id, render: renderFixed, }; diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index d69faad2f56e0..d4d459d18e7ca 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -16,6 +16,7 @@ import { fireEvent, waitFor, within, + act, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {MultiSelector} from './MultiSelector'; @@ -2113,4 +2114,39 @@ describe('MultiSelector popup theme target', () => { expect(popup).not.toBe(layer); expect(layer.contains(popup)).toBe(true); }); + + it('stays closed when the trigger click follows its own light dismiss (#5004)', async () => { + const user = userEvent.setup(); + render( + {}} + />, + ); + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + // The browser dismissed the popup on pointerup and queued the toggle. When + // that event lands before the click — WebKit, or any engine under load — + // the click used to read a closed popup and reopen it. + const popover = document.querySelector('[popover]') as HTMLElement; + // Back to back: the guard window is the length of one gesture, and in a + // browser the click lands a few milliseconds behind the dismissal. + act(() => { + popover.dispatchEvent( + Object.assign(new Event('toggle'), { + oldState: 'open', + newState: 'closed', + }), + ); + }); + // Synchronously, so the click still falls inside the one gesture the guard + // covers — in a browser it lands a few milliseconds behind the dismissal. + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/packages/core/src/MultiSelector/MultiSelector.tsx b/packages/core/src/MultiSelector/MultiSelector.tsx index 321110bdc5de5..8df65c54f62a1 100644 --- a/packages/core/src/MultiSelector/MultiSelector.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.tsx @@ -1047,6 +1047,7 @@ export function MultiSelector({ onKeyDown, onItemMouseEnter, } = useMultiCombobox({ + wasJustDismissed: popover.wasJustDismissed, selectableItems: sortedItems, isDisabled, isOpen: popover.isOpen, @@ -1542,6 +1543,7 @@ export function MultiSelector({ {isBusy && } {hasClear && value.length > 0 && !isDisabled && ( ({ type="button" aria-label={t(STATUS_BUTTON_LABEL_KEY[status.type])} aria-describedby={statusTooltip.describedBy} + {...popover.keepOpenProps} onClick={e => e.stopPropagation()} {...stylex.props( focusOutlineStyles.focusVisible, diff --git a/packages/core/src/MultiSelector/hooks.ts b/packages/core/src/MultiSelector/hooks.ts index 64977c5f1f5e6..cd0644b2f993f 100644 --- a/packages/core/src/MultiSelector/hooks.ts +++ b/packages/core/src/MultiSelector/hooks.ts @@ -34,6 +34,12 @@ interface UseMultiComboboxOptions { * The Delete/Backspace clear path is skipped when false. */ hasValue?: boolean; + /** + * Whether the browser's light dismiss just closed the popup. The trigger + * click that follows belongs to that same press, so acting on it would + * reopen the popup the user just closed. + */ + wasJustDismissed?: () => boolean; listboxId: string; } @@ -62,6 +68,7 @@ export function useMultiCombobox({ onToggle, onClear, hasValue = false, + wasJustDismissed, listboxId, }: UseMultiComboboxOptions): UseMultiComboboxResult { const [highlightedIndex, setHighlightedIndex] = useState(-1); @@ -87,7 +94,7 @@ export function useMultiCombobox({ }, [onClose]); const onTriggerClick = useCallback(() => { - if (isDisabled) { + if (isDisabled || wasJustDismissed?.()) { return; } if (isOpen) { @@ -98,7 +105,7 @@ export function useMultiCombobox({ setHighlightedIndex(0); } } - }, [isDisabled, isOpen, onOpen, closeAndReset, hasSearch]); + }, [isDisabled, wasJustDismissed, isOpen, onOpen, closeAndReset, hasSearch]); const onItemMouseEnter = useCallback( (item: MultiSelectorOptionData, index: number) => { diff --git a/packages/core/src/Popover/Popover.tsx b/packages/core/src/Popover/Popover.tsx index e676dd23041db..ba082561fd77a 100644 --- a/packages/core/src/Popover/Popover.tsx +++ b/packages/core/src/Popover/Popover.tsx @@ -322,16 +322,12 @@ export function Popover({ }: PopoverProps): ReactElement { const wrapperRef = useRef(null); const isControlled = isOpen !== undefined; - // Track when the popover was last hidden by light dismiss to prevent - // the trigger click from immediately re-opening it. - const lastHideTimeRef = useRef(0); const handlePopoverShow = useCallback(() => { onOpenChange?.(true); }, [onOpenChange]); const handlePopoverHide = useCallback(() => { - lastHideTimeRef.current = Date.now(); onOpenChange?.(false); }, [onOpenChange]); @@ -353,11 +349,7 @@ export function Popover({ if (!isEnabled) { return; } - // If the popover was just closed by light dismiss (clicking outside), - // the trigger click fires in the same event — skip re-opening. - if (Date.now() - lastHideTimeRef.current < 50) { - return; - } + // `toggle` absorbs a click that belongs to its own light dismiss. popover.toggle(); }, [isEnabled, popover]); diff --git a/packages/core/src/Popover/usePopover.tsx b/packages/core/src/Popover/usePopover.tsx index edfb95e54d1d5..b982f3ee0abb3 100644 --- a/packages/core/src/Popover/usePopover.tsx +++ b/packages/core/src/Popover/usePopover.tsx @@ -17,7 +17,11 @@ import React, {useCallback, useEffect, useRef, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; -import {useLayer, type ContextRenderProps} from '../Layer/useLayer'; +import { + useLayer, + type ContextRenderProps, + type KeepLayerOpenProps, +} from '../Layer/useLayer'; import {useFocusTrap} from '../hooks/useFocusTrap'; import type {StyleXStyles} from '@stylexjs/stylex'; import { @@ -248,6 +252,22 @@ export interface UsePopoverReturn { */ toggle: () => void; + /** + * Props for a control that sits on the trigger — a clear button, a status + * button — and must not dismiss this popover when pressed. Merge them with + * the control's own handlers. + */ + keepOpenProps: KeepLayerOpenProps; + + /** + * Whether the browser's own light dismiss just closed this popover. + * + * Triggers that do not route through `toggle` — a combobox that also seeds a + * highlight, say — check this first and do nothing when it is true: the click + * is the tail of the gesture that already closed the popup. + */ + wasJustDismissed: () => boolean; + /** * Whether the popover is currently open */ @@ -399,6 +419,9 @@ export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn { // Toggle function const toggle = useCallback(() => { + if (layer.wasJustDismissed()) { + return; + } if (layer.isOpen) { layer.hide(); } else { @@ -488,6 +511,8 @@ export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn { show, hide: layer.hide, toggle, + keepOpenProps: layer.keepOpenProps, + wasJustDismissed: layer.wasJustDismissed, isOpen: layer.isOpen, id: layer.id, render, diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 225aa2326bb28..7d0723a731317 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -16,6 +16,7 @@ import { fireEvent, waitFor, within, + act, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {useState} from 'react'; @@ -2775,4 +2776,40 @@ describe('Selector popup theme target', () => { expect(popup?.querySelector('[role="listbox"]')).not.toBeNull(); }, ); + + it('stays closed when the trigger click follows its own light dismiss (#5004)', async () => { + const user = userEvent.setup(); + render( + {}} + placement="below" + />, + ); + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + // The browser dismissed the popup on pointerup and queued the toggle. When + // that event lands before the click — WebKit, or any engine under load — + // the click used to read a closed popup and reopen it. + const popover = document.querySelector('[popover]') as HTMLElement; + // Back to back: the guard window is the length of one gesture, and in a + // browser the click lands a few milliseconds behind the dismissal. + act(() => { + popover.dispatchEvent( + Object.assign(new Event('toggle'), { + oldState: 'open', + newState: 'closed', + }), + ); + }); + // Synchronously, so the click still falls inside the one gesture the guard + // covers — in a browser it lands a few milliseconds behind the dismissal. + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 3ad734a63072d..6d63ad25535ab 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -909,6 +909,7 @@ export function Selector( onItemMouseEnter, } = useCombobox({ selectableItems: filteredItems, + wasJustDismissed: popover.wasJustDismissed, // The optimistic value, not the raw prop: with a pending changeAction the // prop still holds the old selection, so the popup would open with the // highlight on it and Delete/Backspace could clear a value the action has @@ -1355,6 +1356,7 @@ export function Selector( {isBusy && } {hasClear && value != null && !isDisabled && ( ( type="button" aria-label={t(STATUS_BUTTON_LABEL_KEY[status.type])} aria-describedby={statusTooltip.describedBy} + {...popover.keepOpenProps} onClick={e => e.stopPropagation()} {...stylex.props( focusOutlineStyles.focusVisible, diff --git a/packages/core/src/Selector/hooks.ts b/packages/core/src/Selector/hooks.ts index f5f467c489a85..648ff1e0c38d4 100644 --- a/packages/core/src/Selector/hooks.ts +++ b/packages/core/src/Selector/hooks.ts @@ -176,6 +176,12 @@ interface UseComboboxOptions { * lands in the search input, which then owns its own typing. */ onSearchSeed?: (char: string) => void; + /** + * Whether the browser's light dismiss just closed the popup. The trigger + * click that follows belongs to that same press, so acting on it would + * reopen the popup the user just closed. + */ + wasJustDismissed?: () => boolean; listboxId: string; } @@ -208,6 +214,7 @@ export function useCombobox({ onSelect, onClear, onSearchSeed, + wasJustDismissed, listboxId, }: UseComboboxOptions): UseComboboxResult { const [highlightedIndex, setHighlightedIndex] = useState(-1); @@ -244,7 +251,7 @@ export function useCombobox({ ); const onTriggerClick = useCallback(() => { - if (isDisabled) { + if (isDisabled || wasJustDismissed?.()) { return; } if (isOpen) { @@ -256,7 +263,15 @@ export function useCombobox({ setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0); } } - }, [isDisabled, isOpen, onOpen, closeAndReset, findSelectedIndex, hasSearch]); + }, [ + isDisabled, + wasJustDismissed, + isOpen, + onOpen, + closeAndReset, + findSelectedIndex, + hasSearch, + ]); const onItemMouseEnter = useCallback( (item: SelectorOptionData, index: number) => { From 4947e624cf12a55e5aeb6715a44edeebeb2b81bc Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 22 Aug 2026 06:37:06 -0700 Subject: [PATCH 2/4] fix(core): guard show() itself against the dismissing gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard only reached callers routing through toggle(); show()/hide() callers had to opt in. Guarding in show() holds it for every caller, so ComplexSelector's private 50ms timer goes with it. The dismissal is also one-shot now: it is spent by the click that ends the press it came from, so a later synthesized click — AT activation — is not read as part of a gesture that ended long ago. --- .../ComplexSelector/ComplexSelector.test.tsx | 6 +-- .../src/ComplexSelector/ComplexSelector.tsx | 4 +- .../src/DropdownMenu/DropdownMenu.test.tsx | 52 +++++++++++++++---- packages/core/src/Layer/useLayer.test.tsx | 52 +++++++++++++++++++ packages/core/src/Layer/useLayer.tsx | 52 +++++++++++++++---- .../src/MultiSelector/MultiSelector.test.tsx | 6 +-- packages/core/src/Selector/Selector.test.tsx | 6 +-- 7 files changed, 142 insertions(+), 36 deletions(-) diff --git a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx index 8d8a636fb5576..3a6f2cd06d104 100644 --- a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx +++ b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx @@ -433,8 +433,6 @@ describe('ComplexSelector popup theme target', () => { // that event lands before the click — WebKit, or any engine under load — // the click used to read a closed popup and reopen it. const popover = document.querySelector('[popover]') as HTMLElement; - // Back to back: the guard window is the length of one gesture, and in a - // browser the click lands a few milliseconds behind the dismissal. act(() => { popover.dispatchEvent( Object.assign(new Event('toggle'), { @@ -443,8 +441,8 @@ describe('ComplexSelector popup theme target', () => { }), ); }); - // Synchronously, so the click still falls inside the one gesture the guard - // covers — in a browser it lands a few milliseconds behind the dismissal. + // Synchronously: the click falls inside the one gesture the guard covers, + // as it does in a browser a few milliseconds behind the dismissal. fireEvent.click(trigger); expect(trigger).toHaveAttribute('aria-expanded', 'false'); diff --git a/packages/core/src/ComplexSelector/ComplexSelector.tsx b/packages/core/src/ComplexSelector/ComplexSelector.tsx index 95b6e25f96a6e..77f8e62cc800c 100644 --- a/packages/core/src/ComplexSelector/ComplexSelector.tsx +++ b/packages/core/src/ComplexSelector/ComplexSelector.tsx @@ -373,14 +373,12 @@ export function ComplexSelector({ .join(' ') || undefined; const triggerRef = useRef(null); - const lastHideTimeRef = useRef(0); const [isPending, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || isPending; const handlePopoverHide = useCallback(() => { - lastHideTimeRef.current = Date.now(); triggerRef.current?.focus(); }, []); @@ -395,7 +393,7 @@ export function ComplexSelector({ const isOpen = popover.isOpen; const handleTriggerClick = useCallback(() => { - if (isDisabled || Date.now() - lastHideTimeRef.current < 50) { + if (isDisabled) { return; } if (popover.isOpen) { diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx index c876adf252c0e..34cd910839962 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx @@ -10,7 +10,7 @@ */ import {describe, it, expect, vi, beforeEach} from 'vitest'; -import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import {render, screen, fireEvent, waitFor, act} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {useState} from 'react'; import {DropdownMenu} from './DropdownMenu'; @@ -315,10 +315,7 @@ describe('DropdownMenu', () => { }); describe('DropdownMenu light-dismiss race', () => { - it('does not re-open the menu when a click follows a hide within the guard window', () => { - // Reproduces the iOS Safari race: pointerdown fires light-dismiss before - // the subsequent click on the trigger; without the guard, the click would - // immediately re-open the menu in the same tap. + function openMenu() { render( { data-testid="astryx-dropdown-menu" />, ); - const trigger = screen.getByTestId('astryx-dropdown-menu'); - fireEvent.click(trigger); // open - fireEvent.click(trigger); // close (stamps guard) - fireEvent.click(trigger); // would re-open without guard + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); - expect(HTMLElement.prototype.hidePopover).toHaveBeenCalledTimes(1); + return trigger; + } + + /** + * The browser dismisses the menu on pointerup and queues the `toggle` event; + * on the engines that lose the race it reaches React before the trigger's + * own click, which then reads a closed menu. + */ + function lightDismiss() { + const popover = document.querySelector('[popover]') as HTMLElement; + act(() => { + popover.dispatchEvent( + Object.assign(new Event('toggle'), { + oldState: 'open', + newState: 'closed', + }), + ); + }); + } + + it('does not re-open when the trigger click follows its own light dismiss', () => { + const trigger = openMenu(); + + lightDismiss(); + fireEvent.click(trigger); + + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + }); + + it('re-opens on a press of its own after a light dismiss', () => { + const trigger = openMenu(); + + lightDismiss(); + fireEvent.click(trigger); + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); + + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index bf1ace7c58bd2..93d4fa5d5746c 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -1020,4 +1020,56 @@ describe('wasJustDismissed (light dismiss vs. the trigger click, #5004)', () => expect(getByTestId('state')).toHaveTextContent('open'); }); + + it('acts on a synthesized click with no press of its own', async () => { + const user = userEvent.setup(); + const {container, getByRole, getByTestId} = render( + , + ); + const trigger = getByRole('button', {name: 'Trigger'}); + + await user.click(trigger); + lightDismiss(container); + fireEvent.click(trigger); + expect(getByTestId('state')).toHaveTextContent('closed'); + + // AT activation reaches the trigger as a bare click: no pointerdown, so + // the gesture counter still reads what it read at the dismissal. + act(() => { + trigger.click(); + }); + + expect(getByTestId('state')).toHaveTextContent('open'); + }); + + /** A trigger that calls show()/hide() directly, checking nothing. */ + function PlainTriggerHarness() { + const layer = useLayer({mode: 'context'}); + return ( + <> + + {layer.isOpen ? 'open' : 'closed'} + {layer.render(Layer content, {placement: 'below'})} + + ); + } + + it('absorbs the click for a trigger that never calls wasJustDismissed', async () => { + const user = userEvent.setup(); + const {container, getByRole, getByTestId} = render(); + const trigger = getByRole('button', {name: 'Trigger'}); + + await user.click(trigger); + expect(getByTestId('state')).toHaveTextContent('open'); + + lightDismiss(container); + fireEvent.click(trigger); + + expect(getByTestId('state')).toHaveTextContent('closed'); + }); }); diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index b70f0753b4e01..a172bb5e314e3 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -550,6 +550,14 @@ export function useLayer( // Read through wasJustDismissed by triggers deciding whether a click is // theirs to act on. const dismissedByGestureRef = useRef(null); + const forgetDismissalRef = useRef<(() => void) | null>(null); + + const wasJustDismissed = useCallback( + () => + dismissedByGestureRef.current !== null && + dismissedByGestureRef.current === currentGesture(), + [], + ); const showPopoverElement = useCallback((popover: HTMLElement) => { // Finding infra-4: the Popover API is unsupported on Safari <17 and @@ -614,6 +622,11 @@ export function useLayer( }, [mode, lazyMount]); const show = useCallback(() => { + // Every caller lands here, so this is where the dismissing press is + // absorbed: opening now would reopen the popup that same press closed. + if (wasJustDismissed()) { + return; + } // A context popover left over until React commits a previous hide must not // be reopened. The synchronous mount ref is the source of truth. const candidate = popoverRef.current; @@ -635,12 +648,18 @@ export function useLayer( requestContextMount, showPopoverElement, isCurrentContextPopover, + wasJustDismissed, ]); const hide = useCallback(() => { pendingShowRef.current = false; if (isOpenRef.current) { const el = popoverRef.current; + // Clear the open state BEFORE hiding: hidePopover fires `toggle`, and + // the reconciler below reads this ref to tell the browser's own + // dismissals from ours. + openedPopoverRef.current = null; + isOpenRef.current = false; // See finding infra-4 note in `show`: mirror the same guard on hide so // unsupported browsers degrade gracefully instead of throwing. if (el) { @@ -650,21 +669,12 @@ export function useLayer( el.style.display = 'none'; } } - openedPopoverRef.current = null; - isOpenRef.current = false; setIsOpen(false); onHide?.(); } clearContextMount(); }, [onHide, clearContextMount]); - const wasJustDismissed = useCallback( - () => - dismissedByGestureRef.current !== null && - dismissedByGestureRef.current === currentGesture(), - [], - ); - const keepOpenProps: KeepLayerOpenProps = useMemo( () => ({ onPointerDown: (event: React.PointerEvent) => { @@ -720,6 +730,24 @@ export function useLayer( } : undefined; + // A dismissal is spent by the click that ends the press it came from. Left + // standing it would also swallow a click that arrives with no press of its + // own — AT activation, element.click() — which never advances the counter. + // Bubble phase on the document: every guard reading the dismissal has run. + const rememberDismissal = useCallback((doc: Document) => { + dismissedByGestureRef.current = currentGesture(); + forgetDismissalRef.current?.(); + const forget = () => { + dismissedByGestureRef.current = null; + doc.removeEventListener('click', forget); + forgetDismissalRef.current = null; + }; + doc.addEventListener('click', forget); + forgetDismissalRef.current = forget; + }, []); + + useEffect(() => () => forgetDismissalRef.current?.(), []); + // Reconcile browser-initiated closes (light-dismiss, popover="auto" stack // eviction). These are the only cases where the DOM mutates without going // through our show/hide — we sync React state back to match. @@ -736,13 +764,15 @@ export function useLayer( if (toggleEvent.newState === 'closed' && isOpenRef.current) { openedPopoverRef.current = null; isOpenRef.current = false; - dismissedByGestureRef.current = currentGesture(); + rememberDismissal( + (e.currentTarget as HTMLElement | null)?.ownerDocument ?? document, + ); setIsOpen(false); onHide?.(); clearContextMount(); } }, - [onHide, clearContextMount], + [onHide, clearContextMount, rememberDismissal], ); // Ref callback for popover element — sets up the `toggle` listener. diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 0a6672a1b3f39..4130f05382c76 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -2403,8 +2403,6 @@ describe('MultiSelector popup theme target', () => { // that event lands before the click — WebKit, or any engine under load — // the click used to read a closed popup and reopen it. const popover = document.querySelector('[popover]') as HTMLElement; - // Back to back: the guard window is the length of one gesture, and in a - // browser the click lands a few milliseconds behind the dismissal. act(() => { popover.dispatchEvent( Object.assign(new Event('toggle'), { @@ -2413,8 +2411,8 @@ describe('MultiSelector popup theme target', () => { }), ); }); - // Synchronously, so the click still falls inside the one gesture the guard - // covers — in a browser it lands a few milliseconds behind the dismissal. + // Synchronously: the click falls inside the one gesture the guard covers, + // as it does in a browser a few milliseconds behind the dismissal. fireEvent.click(trigger); expect(trigger).toHaveAttribute('aria-expanded', 'false'); diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 33e790b94331d..910c6b3bba589 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -3016,8 +3016,6 @@ describe('Selector popup theme target', () => { // that event lands before the click — WebKit, or any engine under load — // the click used to read a closed popup and reopen it. const popover = document.querySelector('[popover]') as HTMLElement; - // Back to back: the guard window is the length of one gesture, and in a - // browser the click lands a few milliseconds behind the dismissal. act(() => { popover.dispatchEvent( Object.assign(new Event('toggle'), { @@ -3026,8 +3024,8 @@ describe('Selector popup theme target', () => { }), ); }); - // Synchronously, so the click still falls inside the one gesture the guard - // covers — in a browser it lands a few milliseconds behind the dismissal. + // Synchronously: the click falls inside the one gesture the guard covers, + // as it does in a browser a few milliseconds behind the dismissal. fireEvent.click(trigger); expect(trigger).toHaveAttribute('aria-expanded', 'false'); From e6dcd044ec69cbaa924cf1988f616bafb86dcc4c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 24 Aug 2026 21:19:32 -0700 Subject: [PATCH 3/4] fix(core): take the invoker off when a press ends in pointercancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `keepOpenProps` stamped `popovertarget` on the control and scheduled its removal from a `pointerup` listener alone. A press has two ends: a touch the browser takes over for a scroll, and a long press that opens the platform menu, both fire `pointercancel` and no `pointerup`. The attribute survived the press, and Chrome's a11y tree then reported the clear button as an expanded pop-up button — the permanent invoker the comment above the handler rules out. The `{once: true}` listener leaked with it. Both ends now share one cleanup, so whichever arrives first removes the attribute and both listeners. That is what the other press cleanups in core do: ResizeHandle, useTableColumnResize, usePointerDragScroll, useSheetGestures. Driven in Chromium — touchStart then touchCancel, no touchEnd, on the clear button of an open MultiSelector — the AX node loses `expanded=true` and the attribute is gone; an ordinary tap still clears the value with the menu open. --- packages/core/src/Layer/useLayer.test.tsx | 23 +++++++++++++++++++ packages/core/src/Layer/useLayer.tsx | 27 ++++++++++++++--------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index 93d4fa5d5746c..6f5d478497550 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -905,6 +905,29 @@ describe('keepOpenProps (controls on the trigger, #5004)', () => { expect(clear).not.toHaveAttribute('popovertarget'); }); + it('takes the invoker off when the press ends in pointercancel', async () => { + const user = userEvent.setup(); + const {container, getByRole} = render(); + const clear = getByRole('button', {name: 'Clear'}); + + await user.click(getByRole('button', {name: 'Trigger'})); + const popover = container.querySelector('[popover]') as HTMLElement; + + await user.pointer({keys: '[MouseLeft>]', target: clear}); + expect(clear.getAttribute('popovertarget')).toBe(popover.id); + + // A touch the browser claims — for a scroll, for the platform's long-press + // menu — ends here, and no pointerup ever arrives. + await act(async () => { + document.dispatchEvent(new Event('pointercancel')); + await new Promise(resolve => { + window.setTimeout(resolve, 0); + }); + }); + + expect(clear).not.toHaveAttribute('popovertarget'); + }); + it('leaves the control alone while the layer is closed', async () => { const user = userEvent.setup(); const {getByRole} = render(); diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index a172bb5e314e3..b94aa2a2a895a 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -688,17 +688,22 @@ export function useLayer( // `expanded` to assistive tech, which a clear button is not. const control = event.currentTarget; control.setAttribute('popovertarget', id); - document.addEventListener( - 'pointerup', - () => { - // A task, not a microtask: the dismissal runs as the pointerup - // default action, after this listener. - window.setTimeout(() => { - control.removeAttribute('popovertarget'); - }, 0); - }, - {once: true}, - ); + // A press has two ends: `pointercancel` fires instead of `pointerup` + // when the browser claims the gesture — a touch turned into a scroll, + // a long press opening the platform menu. Listening for `pointerup` + // alone leaves the attribute on, and a permanent invoker reports + // itself `expanded` to assistive tech. + const onPressEnd = () => { + document.removeEventListener('pointerup', onPressEnd); + document.removeEventListener('pointercancel', onPressEnd); + // A task, not a microtask: the dismissal runs as the pointerup + // default action, after this listener. + window.setTimeout(() => { + control.removeAttribute('popovertarget'); + }, 0); + }; + document.addEventListener('pointerup', onPressEnd); + document.addEventListener('pointercancel', onPressEnd); }, onClickCapture: (event: React.MouseEvent) => { const control = event.currentTarget; From 8a9f5d94728c41173e72ba2a85abe65c74ede3bd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Tue, 25 Aug 2026 10:13:17 -0700 Subject: [PATCH 4/4] fix(test): drop the duplicate act import the merge left behind --- packages/core/src/MultiSelector/MultiSelector.test.tsx | 1 - packages/core/src/Selector/Selector.test.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index db99df8b0bc0e..23cf18e69e90e 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -17,7 +17,6 @@ import { fireEvent, waitFor, within, - act, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {MultiSelector} from './MultiSelector'; diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 009650069d0b1..2e60813a60725 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -17,7 +17,6 @@ import { fireEvent, waitFor, within, - act, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {useState} from 'react';