diff --git a/packages/@adobe/spectrum-css-temp/components/popover/index.css b/packages/@adobe/spectrum-css-temp/components/popover/index.css index 744aef962ed..c4d786a8b45 100644 --- a/packages/@adobe/spectrum-css-temp/components/popover/index.css +++ b/packages/@adobe/spectrum-css-temp/components/popover/index.css @@ -51,6 +51,14 @@ governing permissions and limitations under the License. overflow: hidden; } +/* Prevent clicks during exit animation (e.g. Picker selecting wrong item). + pointer-events is not inherited, so we must disable it on the popover and all descendants. */ +.spectrum-Popover.is-exiting, +.spectrum-Popover.is-exiting * { + pointer-events: none; + touch-action: none; +} + .is-open { composes: spectrum-overlay--open; } diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 78bff4f50af..2f09ab5bb2f 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -22,7 +22,8 @@ import {useRef} from 'react'; * A range calendar displays one or more date grids and allows users to select a contiguous range of dates. */ export function useRangeCalendar(props: AriaRangeCalendarProps, state: RangeCalendarState, ref: RefObject): CalendarAria { - let res = useCalendarBase(props, state); + let {interactOutsideBehavior = 'select', ...otherProps} = props; + let res = useCalendarBase(otherProps, state); // We need to ignore virtual pointer events from VoiceOver due to these bugs. // https://bugs.webkit.org/show_bug.cgi?id=222627 @@ -36,7 +37,13 @@ export function useRangeCalendar(props: AriaRangeCalendarPr isVirtualClick.current = e.width === 0 && e.height === 0; }); - // Stop range selection when pressing or releasing a pointer outside the calendar body, + const interactOutsideBehaviorMapping = { + clear: () => state.clearSelection(), + reset: () => state.setAnchorDate(null), + select: () => state.selectFocusedDate() + }; + + // Execute method corresponding to `interactOutsideBehavior` when pressing or releasing a pointer outside the calendar body, // except when pressing the next or previous buttons to switch months. let endDragging = (e: PointerEvent) => { if (isVirtualClick.current) { @@ -55,19 +62,21 @@ export function useRangeCalendar(props: AriaRangeCalendarPr isFocusWithin(ref.current) && (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { - state.selectFocusedDate(); + interactOutsideBehaviorMapping[interactOutsideBehavior](); } }; useEvent(windowRef, 'pointerup', endDragging); - // Also stop range selection on blur, e.g. tabbing away from the calendar. + // Also execute method corresponding to `interactOutsideBehavior` on blur, + // e.g. tabbing away from the calendar. res.calendarProps.onBlur = e => { if (!ref.current) { return; } + if ((!e.relatedTarget || !nodeContains(ref.current, e.relatedTarget)) && state.anchorDate) { - state.selectFocusedDate(); + interactOutsideBehaviorMapping[interactOutsideBehavior](); } }; diff --git a/packages/@react-aria/interactions/src/useHover.ts b/packages/@react-aria/interactions/src/useHover.ts index 5a1d94c4eee..639f598e7a8 100644 --- a/packages/@react-aria/interactions/src/useHover.ts +++ b/packages/@react-aria/interactions/src/useHover.ts @@ -45,7 +45,7 @@ function setGlobalIgnoreEmulatedMouseEvents() { // the distant future because a user previously touched the element. setTimeout(() => { globalIgnoreEmulatedMouseEvents = false; - }, 50); + }, 500); } function handleGlobalPointerEvent(e: PointerEvent) { diff --git a/packages/@react-aria/interactions/test/useHover.test.js b/packages/@react-aria/interactions/test/useHover.test.js index 144610513ca..c3e21425122 100644 --- a/packages/@react-aria/interactions/test/useHover.test.js +++ b/packages/@react-aria/interactions/test/useHover.test.js @@ -191,7 +191,7 @@ describe('useHover', function () { fireEvent(el, pointerEvent('pointerout', {pointerType: 'touch'})); fireEvent(el, pointerEvent('pointerup', {pointerType: 'touch'})); - act(() => {jest.advanceTimersByTime(100);}); + act(() => {jest.advanceTimersByTime(600);}); // Safari on iOS has a bug that fires a pointer event with pointerType="mouse" on focus. // See https://bugs.webkit.org/show_bug.cgi?id=214609. @@ -321,7 +321,7 @@ describe('useHover', function () { let el = res.getByTestId('test'); fireEvent(el, pointerEvent('pointerover', {pointerType: 'mouse'})); expect(el).toHaveAttribute('data-hovered', 'true'); - + let button = res.getByRole('button'); act(() => button.click()); expect(button).not.toBeInTheDocument(); @@ -444,7 +444,7 @@ describe('useHover', function () { fireEvent.mouseLeave(el); fireEvent.touchEnd(el); - act(() => {jest.advanceTimersByTime(100);}); + act(() => {jest.advanceTimersByTime(600);}); // Safari on iOS has a bug that fires a mouse event on focus. // See https://bugs.webkit.org/show_bug.cgi?id=214609. diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index ad0bdeaee33..a9644e3d1e1 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -15,6 +15,10 @@ import {shadowDOM} from '@react-stately/flags'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; +function isAlwaysVisibleNode(node: HTMLElement | SVGElement): boolean { + return node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer !== undefined; +} + interface AriaHideOutsideOptions { root?: Element, shouldUseInert?: boolean @@ -175,7 +179,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && - (node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true') + isAlwaysVisibleNode(node) ) { visibleNodes.add(node); } else if (node instanceof Element) { @@ -218,7 +222,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && - (node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true') + isAlwaysVisibleNode(node) ) { visibleNodes.add(node); } else if (node instanceof Element) { diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index a0c8bd2d198..ee386f9efff 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -239,6 +239,35 @@ describe('ariaHideOutside', function () { expect(getAllByRole('checkbox')).toHaveLength(1); }); + it('should recognize dynamically added top layer element with data-react-aria-top-layer="" (attribute presence)', async function () { + let Test = props => ( + <> + + {props.show && ( + <> +
Top layer (empty attr)
+ + + )} + + ); + + let {getByRole, getAllByRole, rerender} = render(); + + let button = getByRole('button'); + let revert = ariaHideOutside([button]); + + rerender(); + + // MutationObserver is async + await Promise.resolve(); + expect(getByRole('status')).not.toHaveAttribute('aria-hidden'); + expect(getByRole('checkbox', {hidden: true})).toHaveAttribute('aria-hidden', 'true'); + + revert(); + expect(getAllByRole('checkbox')).toHaveLength(1); + }); + it('should handle when a new element is added inside a target element', async function () { let Test = props => ( <> diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 07efe254bc5..b2656bc22cf 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -157,7 +157,7 @@ export function HiddenSelect(props: Hidde