diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts
index aabeac2f9a5..9db8185c699 100644
--- a/packages/@react-aria/calendar/src/useCalendarCell.ts
+++ b/packages/@react-aria/calendar/src/useCalendarCell.ts
@@ -338,7 +338,13 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
// outside the original pressed element.
// (JSDOM does not support this)
if ('releasePointerCapture' in e.target) {
- e.target.releasePointerCapture(e.pointerId);
+ if ('hasPointerCapture' in e.target) {
+ if (e.target.hasPointerCapture(e.pointerId)) {
+ e.target.releasePointerCapture(e.pointerId);
+ }
+ } else {
+ e.target.releasePointerCapture(e.pointerId);
+ }
}
},
onContextMenu(e) {
diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts
index 6dc4fd7f757..b9caaec7f6b 100644
--- a/packages/@react-aria/interactions/src/usePress.ts
+++ b/packages/@react-aria/interactions/src/usePress.ts
@@ -596,7 +596,13 @@ export function usePress(props: PressHookProps): PressResult {
// This enables onPointerLeave and onPointerEnter to fire.
let target = getEventTarget(e.nativeEvent);
if ('releasePointerCapture' in target) {
- target.releasePointerCapture(e.pointerId);
+ if ('hasPointerCapture' in target) {
+ if (target.hasPointerCapture(e.pointerId)) {
+ target.releasePointerCapture(e.pointerId);
+ }
+ } else {
+ (target as Element).releasePointerCapture(e.pointerId);
+ }
}
}
diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js
index 92a07a57c0e..4d4af84ba29 100644
--- a/packages/@react-aria/interactions/test/usePress.test.js
+++ b/packages/@react-aria/interactions/test/usePress.test.js
@@ -420,8 +420,10 @@ describe('usePress', function () {
let el = res.getByText('test');
el.releasePointerCapture = jest.fn();
+ el.hasPointerCapture = jest.fn().mockReturnValue(true);
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
- expect(el.releasePointerCapture).toHaveBeenCalled();
+ expect(el.hasPointerCapture).toHaveBeenCalledWith(1);
+ expect(el.releasePointerCapture).toHaveBeenCalledWith(1);
// react listens for pointerout and pointerover instead of pointerleave and pointerenter...
fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
fireEvent(document, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
@@ -560,6 +562,16 @@ describe('usePress', function () {
]);
});
+ it('should not call releasePointerCapture when hasPointerCapture returns false', function () {
+ let res = render();
+ let el = res.getByText('test');
+ el.releasePointerCapture = jest.fn();
+ el.hasPointerCapture = jest.fn().mockReturnValue(false);
+ fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
+ expect(el.hasPointerCapture).toHaveBeenCalledWith(1);
+ expect(el.releasePointerCapture).not.toHaveBeenCalled();
+ });
+
it('should handle pointer cancel events', function () {
let events = [];
let addEvent = (e) => events.push(e);
@@ -4011,8 +4023,10 @@ describe('usePress', function () {
const el = shadowRoot.getElementById('testElement');
el.releasePointerCapture = jest.fn();
+ el.hasPointerCapture = jest.fn().mockReturnValue(true);
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
- expect(el.releasePointerCapture).toHaveBeenCalled();
+ expect(el.hasPointerCapture).toHaveBeenCalledWith(1);
+ expect(el.releasePointerCapture).toHaveBeenCalledWith(1);
// react listens for pointerout and pointerover instead of pointerleave and pointerenter...
fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
fireEvent(document, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts
index 753c2a926a3..47217ab4f41 100644
--- a/packages/@react-aria/overlays/src/ariaHideOutside.ts
+++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts
@@ -147,7 +147,12 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
// If the parent element of the added nodes is not within one of the targets,
// and not already inside a hidden node, hide all of the new children.
- if (![...visibleNodes, ...hiddenNodes].some(node => node.contains(change.target))) {
+ if (
+ change.target.isConnected &&
+ ![...visibleNodes, ...hiddenNodes].some((node) =>
+ node.contains(change.target)
+ )
+ ) {
for (let node of change.addedNodes) {
if (
(node instanceof HTMLElement || node instanceof SVGElement) &&
diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js
index 0eddc67e0ac..36f921002b5 100644
--- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js
+++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js
@@ -12,7 +12,7 @@
import {act, render, waitFor} from '@react-spectrum/test-utils-internal';
import {ariaHideOutside} from '../src';
-import React, {useState} from 'react';
+import React, {useRef, useState} from 'react';
describe('ariaHideOutside', function () {
it('should hide everything except the provided element [button]', function () {
@@ -275,6 +275,76 @@ describe('ariaHideOutside', function () {
expect(() => getByTestId('test')).not.toThrow();
});
+ it('should handle when a new element is added and then reparented', async function () {
+
+ let Test = () => {
+ const ref = useRef(null);
+ const mutate = () => {
+ let parent = document.createElement('ul');
+ let child = document.createElement('li');
+ ref.current.append(parent);
+ parent.appendChild(child);
+ parent.remove(); // this results in a mutation record for a disconnected ul with a connected li (through the new ul parent) in `addedNodes`
+ let newParent = document.createElement('ul');
+ newParent.appendChild(child);
+ ref.current.append(newParent);
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+
+ let {queryByRole, getAllByRole, getByTestId} = render();
+
+ ariaHideOutside([getByTestId('test')]);
+
+ queryByRole('button').click();
+ await Promise.resolve(); // Wait for mutation observer tick
+
+ expect(getAllByRole('listitem')).toHaveLength(1);
+ });
+
+ it('should handle when a new element is added and then reparented to a hidden container', async function () {
+
+ let Test = () => {
+ const ref = useRef(null);
+ const mutate = () => {
+ let parent = document.createElement('ul');
+ let child = document.createElement('li');
+ ref.current.append(parent);
+ parent.appendChild(child);
+ parent.remove(); // this results in a mutation record for a disconnected ul with a connected li (through the new ul parent) in `addedNodes`
+ let newParent = document.createElement('ul');
+ newParent.appendChild(child);
+ ref.current.append(newParent);
+ };
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ };
+
+ let {queryByRole, queryAllByRole, getByTestId} = render();
+
+ ariaHideOutside([getByTestId('test')]);
+
+ queryByRole('button').click();
+ await Promise.resolve(); // Wait for mutation observer tick
+
+ expect(queryAllByRole('listitem')).toHaveLength(0);
+ });
+
+
it('work when called multiple times', function () {
let {getByRole, getAllByRole} = render(
<>
diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts
index f44cb0a123d..b747973a80f 100644
--- a/packages/@react-aria/selection/src/useSelectableCollection.ts
+++ b/packages/@react-aria/selection/src/useSelectableCollection.ts
@@ -118,7 +118,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
disallowTypeAhead = false,
shouldUseVirtualFocus,
allowsTabNavigation = false,
- isVirtualized,
// If no scrollRef is provided, assume the collection ref is the scrollable region
scrollRef = ref,
linkBehavior = 'action'
@@ -328,7 +327,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
// Store the scroll position so we can restore it later.
/// TODO: should this happen all the time??
let scrollPos = useRef({top: 0, left: 0});
- useEvent(scrollRef, 'scroll', isVirtualized ? undefined : () => {
+ useEvent(scrollRef, 'scroll', () => {
scrollPos.current = {
top: scrollRef.current?.scrollTop ?? 0,
left: scrollRef.current?.scrollLeft ?? 0
@@ -369,7 +368,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
} else {
navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.());
}
- } else if (!isVirtualized && scrollRef.current) {
+ } else if (scrollRef.current) {
// Restore the scroll position to what it was before.
scrollRef.current.scrollTop = scrollPos.current.top;
scrollRef.current.scrollLeft = scrollPos.current.left;
@@ -581,7 +580,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
// This will be marshalled to either the first or last item depending on where focus came from.
let tabIndex: number | undefined = undefined;
if (!shouldUseVirtualFocus) {
- tabIndex = manager.focusedKey == null ? 0 : -1;
+ tabIndex = manager.isFocused ? -1 : 0;
}
let collectionId = useCollectionId(manager.collection);
diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx
index 0f336cd54d2..ab2348328b6 100644
--- a/packages/@react-spectrum/s2/src/ComboBox.tsx
+++ b/packages/@react-spectrum/s2/src/ComboBox.tsx
@@ -79,7 +79,7 @@ export interface ComboboxStyleProps {
size?: 'S' | 'M' | 'L' | 'XL'
}
export interface ComboBoxProps extends
- Omit, 'children' | 'style' | 'className' | 'defaultFilter' | 'allowsEmptyCollection' | keyof GlobalDOMAttributes>,
+ Omit, 'children' | 'style' | 'className' | 'defaultFilter' | 'allowsEmptyCollection' | 'isTriggerUpWhenOpen' | keyof GlobalDOMAttributes>,
ComboboxStyleProps,
StyleProps,
SpectrumLabelableProps,
@@ -354,6 +354,7 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co
return (
pressScale(buttonRef)(renderProps)}
className={renderProps => inputButton({
...renderProps,
diff --git a/packages/@react-spectrum/s2/src/DatePicker.tsx b/packages/@react-spectrum/s2/src/DatePicker.tsx
index e707c911ffe..b98a99ef1ad 100644
--- a/packages/@react-spectrum/s2/src/DatePicker.tsx
+++ b/packages/@react-spectrum/s2/src/DatePicker.tsx
@@ -41,7 +41,7 @@ import {useSpectrumContextProps} from './useSpectrumContextProps';
export interface DatePickerProps extends
- Omit, 'children' | 'className' | 'style' | keyof GlobalDOMAttributes>,
+ Omit, 'children' | 'className' | 'style' | 'isTriggerUpWhenOpen' | keyof GlobalDOMAttributes>,
Pick, 'createCalendar' | 'pageBehavior' | 'firstDayOfWeek' | 'isDateUnavailable'>,
Pick,
StyleProps,
@@ -155,6 +155,7 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function
ref={ref}
isRequired={isRequired}
{...dateFieldProps}
+ isTriggerUpWhenOpen
style={UNSAFE_style}
className={(UNSAFE_className || '') + style(field(), getAllowedOverrides())({
isInForm: !!formContext,
@@ -277,9 +278,6 @@ export function CalendarButton(props: {isOpen: boolean, size: 'S' | 'M' | 'L' |
return (