diff --git a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch new file mode 100644 index 00000000000..a602426af6f --- /dev/null +++ b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch @@ -0,0 +1,26 @@ +diff --git a/dist/cjs/document/prepareDocument.js b/dist/cjs/document/prepareDocument.js +index 39a24b8f2ccdc52739d130480ab18975073616cb..0c3f5199401c15b90230c25a02de364eeef3e297 100644 +--- a/dist/cjs/document/prepareDocument.js ++++ b/dist/cjs/document/prepareDocument.js +@@ -30,7 +30,7 @@ function prepareDocument(document) { + const initialValue = UI.getInitialValue(el); + if (initialValue !== undefined) { + if (el.value !== initialValue) { +- dispatchEvent.dispatchDOMEvent(el, 'change'); ++ el.dispatchEvent(new Event('change')); + } + UI.clearInitialValue(el); + } +diff --git a/dist/cjs/utils/focus/getActiveElement.js b/dist/cjs/utils/focus/getActiveElement.js +index d25f3a8ef67e856e43614559f73012899c0b53d7..4ed9ee45565ed438ee9284d8d3043c0bd50463eb 100644 +--- a/dist/cjs/utils/focus/getActiveElement.js ++++ b/dist/cjs/utils/focus/getActiveElement.js +@@ -6,6 +6,8 @@ function getActiveElement(document) { + const activeElement = document.activeElement; + if (activeElement === null || activeElement === undefined ? undefined : activeElement.shadowRoot) { + return getActiveElement(activeElement.shadowRoot); ++ } else if (activeElement && activeElement.tagName === 'IFRAME') { ++ return getActiveElement(activeElement.contentWindow.document); + } else { + // Browser does not yield disabled elements as document.activeElement - jsdom does + if (isDisabled.isDisabled(activeElement)) { diff --git a/package.json b/package.json index 80145353128..29ff07af557 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^15.0.7", - "@testing-library/user-event": "^14.6.1", + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", "@types/react": "npm:types-react@19.0.0-rc.0", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.0", "@types/storybook__react": "^4.0.2", @@ -234,7 +234,8 @@ "@types/react-dom": "npm:types-react-dom@19.0.0-rc.0", "recast": "0.23.6", "ast-types": "0.16.1", - "svgo": "^3" + "svgo": "^3", + "@testing-library/user-event@npm:^14.4.0": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" }, "@parcel/transformer-css": { "cssModules": { diff --git a/packages/@react-aria/autocomplete/package.json b/packages/@react-aria/autocomplete/package.json index 37bbdab80c6..889296b741d 100644 --- a/packages/@react-aria/autocomplete/package.json +++ b/packages/@react-aria/autocomplete/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@react-aria/combobox": "^3.11.1", + "@react-aria/focus": "^3.19.1", "@react-aria/i18n": "^3.12.5", "@react-aria/interactions": "^3.23.0", "@react-aria/listbox": "^3.14.0", diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 3df6edcfb84..9467afbfca1 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,10 +13,12 @@ import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, isCtrlKeyPressed, mergeProps, mergeRefs, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus'; +import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface CollectionOptions extends DOMProps, AriaLabelingProps { @@ -34,6 +36,8 @@ export interface AriaAutocompleteProps extends AutocompleteProps { } export interface AriaAutocompleteOptions extends Omit { + /** The ref for the wrapped collection element. */ + inputRef: RefObject, /** The ref for the wrapped collection element. */ collectionRef: RefObject } @@ -57,6 +61,7 @@ export interface AutocompleteAria { */ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria { let { + inputRef, collectionRef, filter } = props; @@ -64,29 +69,41 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: let collectionId = useId(); let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); - let queuedActiveDescendant = useRef(null); + let queuedActiveDescendant = useRef(null); let lastCollectionNode = useRef(null); - let updateActiveDescendant = useEffectEvent((e) => { - let {target} = e; - if (queuedActiveDescendant.current === target.id) { - return; + // For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually + // moving focus back to the subtriggers + let shouldUseVirtualFocus = getInteractionModality() !== 'virtual'; + + useEffect(() => { + return () => clearTimeout(timeout.current); + }, []); + + let updateActiveDescendant = useEffectEvent((e: Event) => { + // Ensure input is focused if the user clicks on the collection directly. + if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) { + inputRef.current.focus(); } + let target = e.target as Element | null; + if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) { + return; + } + clearTimeout(timeout.current); - e.stopPropagation(); - if (target !== collectionRef.current) { if (delayNextActiveDescendant.current) { queuedActiveDescendant.current = target.id; timeout.current = setTimeout(() => { state.setFocusedNodeId(target.id); - queuedActiveDescendant.current = null; }, 500); } else { + queuedActiveDescendant.current = target.id; state.setFocusedNodeId(target.id); } } else { + queuedActiveDescendant.current = null; state.setFocusedNodeId(null); } @@ -96,14 +113,14 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: let callbackRef = useCallback((collectionNode) => { if (collectionNode != null) { // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement - // of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update + // of the letter you just typed. If we recieve another focus event then we clear the queued update // We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles // React 19's extra call of the callback ref in strict mode - lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant); lastCollectionNode.current = collectionNode; - collectionNode.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + collectionNode.addEventListener('focusin', updateActiveDescendant); } else { - lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant); + lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant); } }, [updateActiveDescendant]); @@ -123,11 +140,16 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: ); }); - let clearVirtualFocus = useEffectEvent(() => { + let clearVirtualFocus = useEffectEvent((clearFocusKey?: boolean) => { + moveVirtualFocus(getActiveElement()); + queuedActiveDescendant.current = null; state.setFocusedNodeId(null); let clearFocusEvent = new CustomEvent(CLEAR_FOCUS_EVENT, { cancelable: true, - bubbles: true + bubbles: true, + detail: { + clearFocusKey + } }); clearTimeout(timeout.current); delayNextActiveDescendant.current = false; @@ -141,7 +163,8 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: if (state.inputValue !== value && state.inputValue.length <= value.length) { focusFirstItem(); } else { - clearVirtualFocus(); + // Fully clear focused key when backspacing since the list may change and thus we'd want to start fresh again + clearVirtualFocus(true); } state.setInputValue(value); @@ -155,6 +178,7 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: return; } + let focusedNodeId = queuedActiveDescendant.current; switch (e.key) { case 'a': if (isCtrlKeyPressed(e)) { @@ -185,7 +209,7 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: case 'PageUp': case 'ArrowUp': case 'ArrowDown': { - if ((e.key === 'Home' || e.key === 'End') && state.focusedNodeId == null && e.shiftKey) { + if ((e.key === 'Home' || e.key === 'End') && focusedNodeId == null && e.shiftKey) { return; } @@ -200,13 +224,6 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: collectionRef.current?.dispatchEvent(focusCollection); break; } - case 'ArrowLeft': - case 'ArrowRight': - // TODO: will need to special case this so it doesn't clear the focused key if we are currently - // focused on a submenutrigger? May not need to since focus would - // But what about wrapped grids where ArrowLeft and ArrowRight should navigate left/right - clearVirtualFocus(); - break; } // Emulate the keyboard events that happen in the input field in the wrapped collection. This is for triggering things like onAction via Enter @@ -217,15 +234,28 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: e.stopPropagation(); } - if (state.focusedNodeId == null) { - collectionRef.current?.dispatchEvent( + let shouldPerformDefaultAction = true; + if (focusedNodeId == null) { + shouldPerformDefaultAction = collectionRef.current?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ); + ) || false; } else { - let item = document.getElementById(state.focusedNodeId); - item?.dispatchEvent( + let item = document.getElementById(focusedNodeId); + shouldPerformDefaultAction = item?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ); + ) || false; + } + + if (shouldPerformDefaultAction) { + switch (e.key) { + case 'ArrowLeft': + case 'ArrowRight': { + // Clear the activedescendant so NVDA announcements aren't interrupted but retain the focused key in the collection so the + // user's keyboard navigation restarts from where they left off + clearVirtualFocus(); + break; + } + } } }; @@ -235,12 +265,13 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: // is detected by usePress instead of the original keyup originating from the input if (e.target === keyDownTarget.current) { e.stopImmediatePropagation(); - if (state.focusedNodeId == null) { + let focusedNodeId = queuedActiveDescendant.current; + if (focusedNodeId == null) { collectionRef.current?.dispatchEvent( new KeyboardEvent(e.type, e) ); } else { - let item = document.getElementById(state.focusedNodeId); + let item = document.getElementById(focusedNodeId); item?.dispatchEvent( new KeyboardEvent(e.type, e) ); @@ -269,6 +300,34 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: return true; }, [state.inputValue, filter]); + // Be sure to clear/restore the virtual + collection focus when blurring/refocusing the field so we only show the + // focus ring on the virtually focused collection when are actually interacting with the Autocomplete + let onBlur = (e: ReactFocusEvent) => { + if (!e.isTrusted) { + return; + } + + let lastFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; + if (lastFocusedNode) { + dispatchVirtualBlur(lastFocusedNode, e.relatedTarget); + } + }; + + let onFocus = (e: ReactFocusEvent) => { + if (!e.isTrusted) { + return; + } + + let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; + if (curFocusedNode) { + let target = e.target; + queueMicrotask(() => { + dispatchVirtualBlur(target, curFocusedNode); + dispatchVirtualFocus(curFocusedNode, target); + }); + } + }; + return { textFieldProps: { value: state.inputValue, @@ -283,11 +342,13 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: // This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions. autoCorrect: 'off', // This disable's the macOS Safari spell check auto corrections. - spellCheck: 'false' + spellCheck: 'false', + [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: 'enter', + onBlur, + onFocus }, collectionProps: mergeProps(collectionProps, { - // TODO: shouldFocusOnHover? shouldFocusWrap? Should it be up to the wrapped collection? - shouldUseVirtualFocus: true, + shouldUseVirtualFocus, disallowTypeAhead: true }), collectionRef: mergedCollectionRef, diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 66734e80dd6..a3fdd5588fd 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -228,7 +228,7 @@ export class BaseCollection implements ICollection> { let clonedSection: Mutable> = (node as CollectionNode).clone(); let lastChildInSection: Mutable> | null = null; for (let child of this.getChildren(node.key)) { - if (filterFn(child.textValue) || child.type === 'header') { + if (shouldKeepNode(child, filterFn, this, newCollection)) { let clonedChild: Mutable> = (child as CollectionNode).clone(); // eslint-disable-next-line max-depth if (lastChildInSection == null) { @@ -288,22 +288,25 @@ export class BaseCollection implements ICollection> { lastNode = clonedSeparator; newCollection.addNode(clonedSeparator); } - } else if (filterFn(node.textValue)) { + } else { + // At this point, the node is either a subdialogtrigger node or a standard row/item let clonedNode: Mutable> = (node as CollectionNode).clone(); - if (newCollection.firstKey == null) { - newCollection.firstKey = clonedNode.key; - } + if (shouldKeepNode(clonedNode, filterFn, this, newCollection)) { + if (newCollection.firstKey == null) { + newCollection.firstKey = clonedNode.key; + } - if (lastNode != null && (lastNode.type !== 'section' && lastNode.type !== 'separator') && lastNode.parentKey === clonedNode.parentKey) { - lastNode.nextKey = clonedNode.key; - clonedNode.prevKey = lastNode.key; - } else { - clonedNode.prevKey = null; - } + if (lastNode != null && (lastNode.type !== 'section' && lastNode.type !== 'separator') && lastNode.parentKey === clonedNode.parentKey) { + lastNode.nextKey = clonedNode.key; + clonedNode.prevKey = lastNode.key; + } else { + clonedNode.prevKey = null; + } - clonedNode.nextKey = null; - newCollection.addNode(clonedNode); - lastNode = clonedNode; + clonedNode.nextKey = null; + newCollection.addNode(clonedNode); + lastNode = clonedNode; + } } } @@ -322,3 +325,22 @@ export class BaseCollection implements ICollection> { return newCollection; } } + +function shouldKeepNode(node: Node, filterFn: (nodeValue: string) => boolean, oldCollection: BaseCollection, newCollection: BaseCollection): boolean { + if (node.type === 'subdialogtrigger' || node.type === 'submenutrigger') { + // Subdialog wrapper should only have one child, if it passes the filter add it to the new collection since we don't need to + // do any extra handling for its first/next key + let triggerChild = [...oldCollection.getChildren(node.key)][0]; + if (triggerChild && filterFn(triggerChild.textValue)) { + let clonedChild: Mutable> = (triggerChild as CollectionNode).clone(); + newCollection.addNode(clonedChild); + return true; + } else { + return false; + } + } else if (node.type === 'header') { + return true; + } else { + return filterFn(node.textValue); + } +} diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index 0dea1d815b9..dd47e076fc9 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -30,7 +30,9 @@ export interface DialogAria { * A dialog is an overlay shown above other content in an application. */ export function useDialog(props: AriaDialogProps, ref: RefObject): DialogAria { - let {role = 'dialog'} = props; + let { + role = 'dialog' + } = props; let titleId: string | undefined = useSlotId(); titleId = props['aria-label'] ? undefined : titleId; diff --git a/packages/@react-aria/dnd/test/dnd.test.js b/packages/@react-aria/dnd/test/dnd.test.js index 421b7127b81..4fd60931351 100644 --- a/packages/@react-aria/dnd/test/dnd.test.js +++ b/packages/@react-aria/dnd/test/dnd.test.js @@ -2269,9 +2269,9 @@ describe('useDrag and useDrop', function () { }); describe('screen reader', () => { - beforeEach(() => { + beforeEach(async () => { // reset focus visible state - fireEvent.focus(document.body); + fireEvent.click(document.body, {detail: 0, pointerType: null}); }); afterEach(async () => { diff --git a/packages/@react-aria/dnd/test/useDraggableCollection.test.js b/packages/@react-aria/dnd/test/useDraggableCollection.test.js index cf9e6a43811..378a076913c 100644 --- a/packages/@react-aria/dnd/test/useDraggableCollection.test.js +++ b/packages/@react-aria/dnd/test/useDraggableCollection.test.js @@ -755,7 +755,7 @@ describe('useDraggableCollection', () => { beforeEach(() => { // reset focus visible state - fireEvent.focus(document.body); + fireEvent.click(document.body, {detail: 0, pointerType: null}); }); afterEach(async () => { diff --git a/packages/@react-aria/dnd/test/useDroppableCollection.test.js b/packages/@react-aria/dnd/test/useDroppableCollection.test.js index 528e1e99c55..137e311a534 100644 --- a/packages/@react-aria/dnd/test/useDroppableCollection.test.js +++ b/packages/@react-aria/dnd/test/useDroppableCollection.test.js @@ -1005,7 +1005,7 @@ describe('useDroppableCollection', () => { describe('screen reader', () => { beforeEach(() => { // reset focus visible state - fireEvent.focus(document.body); + fireEvent.click(document.body, {detail: 0, pointerType: null}); }); afterEach(async () => { diff --git a/packages/@react-aria/focus/src/index.ts b/packages/@react-aria/focus/src/index.ts index dd2f2b106ef..2d4aa6ffd6f 100644 --- a/packages/@react-aria/focus/src/index.ts +++ b/packages/@react-aria/focus/src/index.ts @@ -14,6 +14,7 @@ export {FocusScope, useFocusManager, getFocusableTreeWalker, createFocusManager, export {FocusRing} from './FocusRing'; export {useFocusRing} from './useFocusRing'; export {useHasTabbableChild} from './useHasTabbableChild'; +export {moveVirtualFocus, dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement} from './virtualFocus'; // For backward compatibility. export {isFocusable} from '@react-aria/utils'; export {FocusableProvider, Focusable, useFocusable, focusSafely} from '@react-aria/interactions'; diff --git a/packages/@react-aria/focus/src/virtualFocus.ts b/packages/@react-aria/focus/src/virtualFocus.ts new file mode 100644 index 00000000000..434e4c11463 --- /dev/null +++ b/packages/@react-aria/focus/src/virtualFocus.ts @@ -0,0 +1,33 @@ +import {getActiveElement, getOwnerDocument} from '@react-aria/utils'; + +export function moveVirtualFocus(to: Element | null) { + let from = getVirtuallyFocusedElement(getOwnerDocument(to)); + if (from !== to) { + if (from) { + dispatchVirtualBlur(from, to); + } + if (to) { + dispatchVirtualFocus(to, from); + } + } +} + +export function dispatchVirtualBlur(from: Element, to: Element | null) { + from.dispatchEvent(new FocusEvent('blur', {relatedTarget: to})); + from.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: to})); +} + +export function dispatchVirtualFocus(to: Element, from: Element | null) { + to.dispatchEvent(new FocusEvent('focus', {relatedTarget: from})); + to.dispatchEvent(new FocusEvent('focusin', {bubbles: true, relatedTarget: from})); +} + +export function getVirtuallyFocusedElement(document: Document) { + let activeElement = getActiveElement(document); + let activeDescendant = activeElement?.getAttribute('aria-activedescendant'); + if (activeDescendant) { + return document.getElementById(activeDescendant) || activeElement; + } + + return activeElement; +} diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 40e739717b8..b212e9f1862 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -93,7 +93,7 @@ function handleFocusEvent(e: FocusEvent) { // Firefox fires two extra focus events when the user first clicks into an iframe: // first on the window, then on the document. We ignore these events so they don't // cause keyboard focus rings to appear. - if (e.target === window || e.target === document || ignoreFocusEvent) { + if (e.target === window || e.target === document || ignoreFocusEvent || !e.isTrusted) { return; } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 6f8f392c57a..50b2d7c6015 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -51,6 +51,11 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { }); let onBlur = useCallback((e: FocusEvent) => { + // Ignore events bubbling through portals. + if (!e.currentTarget.contains(e.target)) { + return; + } + // We don't want to trigger onBlurWithin and then immediately onFocusWithin again // when moving focus inside the element. Only trigger if the currentTarget doesn't // include the relatedTarget (where focus is moving). @@ -69,6 +74,11 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { + // Ignore events bubbling through portals. + if (!e.currentTarget.contains(e.target)) { + return; + } + // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. const ownerDocument = getOwnerDocument(e.target); diff --git a/packages/@react-aria/interactions/test/useFocusVisible.test.js b/packages/@react-aria/interactions/test/useFocusVisible.test.js index e0de5313fc6..8cb554b70d1 100644 --- a/packages/@react-aria/interactions/test/useFocusVisible.test.js +++ b/packages/@react-aria/interactions/test/useFocusVisible.test.js @@ -20,7 +20,8 @@ import userEvent from '@testing-library/user-event'; function Example(props) { const {isFocusVisible} = useFocusVisible(); - return
example{isFocusVisible && '-focusVisible'}
; + // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex + return
example{isFocusVisible && '-focusVisible'}
; } function ButtonExample(props) { @@ -31,32 +32,32 @@ function ButtonExample(props) { return ; } -function toggleBrowserTabs() { +function toggleBrowserTabs(win = window) { // this describes Chrome behaviour only, for other browsers visibilitychange fires after all focus events. // leave tab - const lastActiveElement = document.activeElement; + const lastActiveElement = win.document.activeElement; fireEvent(lastActiveElement, new Event('blur')); - fireEvent(window, new Event('blur')); - Object.defineProperty(document, 'visibilityState', { + fireEvent(win, new Event('blur')); + Object.defineProperty(win.document, 'visibilityState', { value: 'hidden', writable: true }); - Object.defineProperty(document, 'hidden', {value: true, writable: true}); - fireEvent(document, new Event('visibilitychange')); + Object.defineProperty(win.document, 'hidden', {value: true, writable: true}); + fireEvent(win.document, new Event('visibilitychange')); // return to tab - Object.defineProperty(document, 'visibilityState', { + Object.defineProperty(win.document, 'visibilityState', { value: 'visible', writable: true }); - Object.defineProperty(document, 'hidden', {value: false, writable: true}); - fireEvent(document, new Event('visibilitychange')); - fireEvent(window, new Event('focus', {target: window})); + Object.defineProperty(win.document, 'hidden', {value: false, writable: true}); + fireEvent(win.document, new Event('visibilitychange')); + fireEvent(win, new Event('focus', {target: win})); fireEvent(lastActiveElement, new Event('focus')); } -function toggleBrowserWindow() { - fireEvent(window, new Event('blur', {target: window})); - fireEvent(window, new Event('focus', {target: window})); +function toggleBrowserWindow(win = window) { + fireEvent(win, new Event('blur', {target: win})); + fireEvent(win, new Event('focus', {target: win})); } describe('useFocusVisible', function () { @@ -69,41 +70,43 @@ describe('useFocusVisible', function () { fireEvent.focus(document.body); }); - it('returns positive isFocusVisible result after toggling browser tabs after keyboard navigation', function () { + it('returns positive isFocusVisible result after toggling browser tabs after keyboard navigation', async function () { render(); + await user.tab(); let el = screen.getByText('example-focusVisible'); - fireEvent.keyDown(el, {key: 'Tab'}); toggleBrowserTabs(); expect(el.textContent).toBe('example-focusVisible'); }); - it('returns negative isFocusVisible result after toggling browser tabs without prior keyboard navigation', function () { + it('returns negative isFocusVisible result after toggling browser tabs without prior keyboard navigation', async function () { render(); + await user.tab(); let el = screen.getByText('example-focusVisible'); - - fireEvent.mouseDown(el); + + await user.click(el); toggleBrowserTabs(); expect(el.textContent).toBe('example'); }); - it('returns positive isFocusVisible result after toggling browser window after keyboard navigation', function () { + it('returns positive isFocusVisible result after toggling browser window after keyboard navigation', async function () { render(); + await user.tab(); let el = screen.getByText('example-focusVisible'); - fireEvent.keyDown(el, {key: 'Tab'}); toggleBrowserWindow(); expect(el.textContent).toBe('example-focusVisible'); }); - it('returns negative isFocusVisible result after toggling browser window without prior keyboard navigation', function () { + it('returns negative isFocusVisible result after toggling browser window without prior keyboard navigation', async function () { render(); + await user.tab(); let el = screen.getByText('example-focusVisible'); - fireEvent.mouseDown(el); + await user.click(el); toggleBrowserWindow(); expect(el.textContent).toBe('example'); @@ -117,6 +120,7 @@ describe('useFocusVisible', function () { window.document.body.appendChild(iframe); iframeRoot = iframe.contentWindow.document.createElement('div'); iframe.contentWindow.document.body.appendChild(iframeRoot); + iframe.contentWindow.document.body.addEventListener('keydown', e => e.stopPropagation()); }); afterEach(async () => { @@ -127,12 +131,13 @@ describe('useFocusVisible', function () { it('sets up focus listener in a different window', async function () { render(, {container: iframeRoot}); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); + expect(iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); }); - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]'); + const el = iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]'); // Focus in iframe before setupFocus should not do anything - fireEvent.focus(iframe.contentWindow.document.body); + await user.click(document.body); + await user.click(el); expect(el.textContent).toBe('example'); // Setup focus in iframe @@ -140,34 +145,35 @@ describe('useFocusVisible', function () { expect(el.textContent).toBe('example'); // Focus in iframe after setupFocus - fireEvent.focus(iframe.contentWindow.document.body); + expect(iframe.contentWindow.document.activeElement).toBe(el); + await user.keyboard('{Enter}'); expect(el.textContent).toBe('example-focusVisible'); }); it('removes event listeners on beforeunload', async function () { - let tree = render(, iframeRoot); + let tree = render(, {container: iframeRoot}); await waitFor(() => { expect(tree.getByTestId('iframe-example')).toBeTruthy(); }); const el = tree.getByTestId('iframe-example'); + addWindowFocusTracking(iframeRoot); // trigger keyboard focus - fireEvent.keyDown(el, {key: 'a'}); - fireEvent.keyUp(el, {key: 'a'}); + await user.tab(); + await user.keyboard('a'); expect(el.textContent).toBe('example-focusVisible'); - fireEvent.mouseDown(el); - fireEvent.mouseUp(el); + await user.click(el); expect(el.textContent).toBe('example'); - + // Focus events after beforeunload no longer work fireEvent(iframe.contentWindow, new Event('beforeunload')); - fireEvent.focus(iframe.contentWindow.document.body); + await user.keyboard('{Enter}'); expect(el.textContent).toBe('example'); }); it('removes event listeners using teardown function', async function () { - let tree = render(, iframeRoot); + let tree = render(, {container: iframeRoot}); let tearDown = addWindowFocusTracking(iframeRoot); await waitFor(() => { @@ -175,16 +181,15 @@ describe('useFocusVisible', function () { }); const el = tree.getByTestId('iframe-example'); // trigger keyboard focus - fireEvent.keyDown(el, {key: 'a'}); - fireEvent.keyUp(el, {key: 'a'}); + await user.tab(); + await user.keyboard('a'); expect(el.textContent).toBe('example-focusVisible'); - fireEvent.mouseDown(el); - fireEvent.mouseUp(el); + await user.click(el); expect(el.textContent).toBe('example'); tearDown(); - fireEvent.focus(iframe.contentWindow.document.body); + await user.keyboard('{Enter}'); expect(el.textContent).toBe('example'); }); @@ -231,17 +236,16 @@ describe('useFocusVisible', function () { // Fire focus in iframe await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); + expect(iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); }); - fireEvent.focus(iframe.contentWindow.document.body); + await user.tab(); // Iframe event listeners - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]'); + const el = iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]'); expect(el.textContent).toBe('example-focusVisible'); // Toggling browser tabs should have the same behavior since the iframe is on the same tab as before. - fireEvent.keyDown(el, {key: 'Tab'}); - toggleBrowserTabs(); + toggleBrowserTabs(iframe.contentWindow); expect(el.textContent).toBe('example-focusVisible'); }); @@ -251,15 +255,15 @@ describe('useFocusVisible', function () { // Fire focus in iframe await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); + expect(iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); }); - fireEvent.focus(iframe.contentWindow.document.body); + await user.tab(); // Iframe event listeners - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]'); + const el = iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]'); expect(el.textContent).toBe('example-focusVisible'); - fireEvent.mouseDown(el); + await user.click(el); expect(el.textContent).toBe('example'); }); @@ -269,15 +273,14 @@ describe('useFocusVisible', function () { // Fire focus in iframe await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); + expect(iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); }); - fireEvent.focus(iframe.contentWindow.document.body); + await user.tab(); // Iframe event listeners - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]'); + const el = iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]'); expect(el.textContent).toBe('example-focusVisible'); - fireEvent.keyDown(el, {key: 'Tab'}); toggleBrowserWindow(); expect(el.textContent).toBe('example-focusVisible'); }); @@ -288,15 +291,15 @@ describe('useFocusVisible', function () { // Fire focus in iframe await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); + expect(iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy(); }); - fireEvent.focus(iframe.contentWindow.document.body); + await user.tab(); // Iframe event listeners - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]'); + const el = iframe.contentWindow.document.body.querySelector('div[id="iframe-example"]'); expect(el.textContent).toBe('example-focusVisible'); - fireEvent.mouseDown(el); + await user.click(el); toggleBrowserWindow(); expect(el.textContent).toBe('example'); }); @@ -307,10 +310,10 @@ describe('useFocusVisible', function () { // Fire focus in iframe await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('button[id="iframe-example"]')).toBeTruthy(); + expect(iframe.contentWindow.document.body.querySelector('button[id="iframe-example"]')).toBeTruthy(); }); - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('button[id="iframe-example"]'); + const el = iframe.contentWindow.document.body.querySelector('button[id="iframe-example"]'); await user.pointer({target: el, keys: '[MouseLeft]'}); await user.keyboard('{Esc}'); diff --git a/packages/@react-aria/listbox/src/useOption.ts b/packages/@react-aria/listbox/src/useOption.ts index 82dc5c3f1a0..a8412d1acb6 100644 --- a/packages/@react-aria/listbox/src/useOption.ts +++ b/packages/@react-aria/listbox/src/useOption.ts @@ -167,7 +167,7 @@ export function useOption(props: AriaOptionProps, state: ListState, ref: R id: descriptionId }, isFocused, - isFocusVisible: isFocused && isFocusVisible(), + isFocusVisible: isFocused && state.selectionManager.isFocused && isFocusVisible(), isSelected, isDisabled, isPressed, diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index af3094df09c..54378d6da3b 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -253,7 +253,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re isDisabled, onHoverStart(e) { // Hovering over an already expanded sub dialog trigger should keep focus in the dialog. - if (!isFocusVisible() && !(isTriggerExpanded && hasPopup === 'dialog')) { + if (!isFocusVisible() && !(isTriggerExpanded && hasPopup)) { selectionManager.setFocused(true); selectionManager.setFocusedKey(key); } @@ -304,9 +304,21 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re return { menuItemProps: { ...ariaProps, - ...mergeProps(domProps, linkProps, isTrigger ? {onFocus: itemProps.onFocus, 'data-key': itemProps['data-key']} : itemProps, pressProps, hoverProps, keyboardProps, focusProps), + ...mergeProps( + domProps, + linkProps, + isTrigger + ? {onFocus: itemProps.onFocus, 'data-key': itemProps['data-key']} + : itemProps, + pressProps, + hoverProps, + keyboardProps, + focusProps, + // Prevent DOM focus from moving on mouse down when using virtual focus or this is a submenu/subdialog trigger. + data.shouldUseVirtualFocus || isTrigger ? {onMouseDown: e => e.preventDefault()} : undefined + ), // If a submenu is expanded, set the tabIndex to -1 so that shift tabbing goes out of the menu instead of the parent menu item. - tabIndex: itemProps.tabIndex != null && isTriggerExpanded ? -1 : itemProps.tabIndex + tabIndex: itemProps.tabIndex != null && isTriggerExpanded && !data.shouldUseVirtualFocus ? -1 : itemProps.tabIndex }, labelProps: { id: labelId @@ -318,7 +330,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re id: keyboardId }, isFocused, - isFocusVisible: isFocused && isFocusVisible(), + isFocusVisible: isFocused && selectionManager.isFocused && isFocusVisible() && !isTriggerExpanded, isSelected, isPressed, isDisabled diff --git a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts index bc30a1241e9..247b3f351f3 100644 --- a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts +++ b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts @@ -1,8 +1,8 @@ import {RefObject} from '@react-types/shared'; import {useEffect, useRef, useState} from 'react'; +import {useEffectEvent, useResizeObserver} from '@react-aria/utils'; import {useInteractionModality} from '@react-aria/interactions'; -import {useResizeObserver} from '@react-aria/utils'; interface SafelyMouseToSubmenuOptions { /** Ref for the parent menu. */ @@ -51,6 +51,14 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions) { let modality = useInteractionModality(); + // Prevent mouse down over safe triangle. Clicking while pointer-events: none is applied + // will cause focus to move unexpectedly since it will go to an element behind the menu. + let onPointerDown = useEffectEvent((e: PointerEvent) => { + if (preventPointerEvents) { + e.preventDefault(); + } + }); + useEffect(() => { if (preventPointerEvents && menuRef.current) { (menuRef.current as HTMLElement).style.pointerEvents = 'none'; @@ -150,12 +158,21 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions) { window.addEventListener('pointermove', onPointerMove); + // Prevent pointer down over the safe triangle. See above comment. + // Do not enable in tests, because JSDom doesn't do hit testing. + if (process.env.NODE_ENV !== 'test') { + window.addEventListener('pointerdown', onPointerDown, true); + } + return () => { window.removeEventListener('pointermove', onPointerMove); + if (process.env.NODE_ENV !== 'test') { + window.removeEventListener('pointerdown', onPointerDown, true); + } clearTimeout(timeout.current); clearTimeout(autoCloseTimeout.current); movementsTowardsSubmenuCount.current = ALLOWED_INVALID_MOVEMENTS; }; - }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, submenuRef]); + }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, onPointerDown, submenuRef]); } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 567a00516f3..e942f9788a6 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,9 +14,10 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; +import {focusWithoutScrolling, useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {getInteractionModality} from '@react-aria/interactions'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; -import {useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; import {useSafelyMouseToSubmenu} from './useSafelyMouseToSubmenu'; @@ -38,7 +39,9 @@ export interface AriaSubmenuTriggerProps { * The delay time in milliseconds for the submenu to appear after hovering over the trigger. * @default 200 */ - delay?: number + delay?: number, + /** Whether the submenu trigger uses virtual focus. */ + shouldUseVirtualFocus?: boolean } interface SubmenuTriggerProps extends Omit { @@ -67,7 +70,7 @@ export interface SubmenuTriggerAria { * @param ref - Ref to the submenu trigger element. */ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: SubmenuTriggerState, ref: RefObject): SubmenuTriggerAria { - let {parentMenuRef, submenuRef, type = 'menu', isDisabled, delay = 200} = props; + let {parentMenuRef, submenuRef, type = 'menu', isDisabled, delay = 200, shouldUseVirtualFocus} = props; let submenuTriggerId = useId(); let overlayId = useId(); let {direction} = useLocale(); @@ -99,21 +102,33 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm switch (e.key) { case 'ArrowLeft': if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) { + e.preventDefault(); e.stopPropagation(); onSubmenuClose(); - ref.current?.focus(); + if (!shouldUseVirtualFocus && ref.current) { + focusWithoutScrolling(ref.current); + } } break; case 'ArrowRight': if (direction === 'rtl' && e.currentTarget.contains(e.target as Element)) { + e.preventDefault(); e.stopPropagation(); onSubmenuClose(); - ref.current?.focus(); + if (!shouldUseVirtualFocus && ref.current) { + focusWithoutScrolling(ref.current); + } } break; case 'Escape': - e.stopPropagation(); - state.closeAll(); + // TODO: can remove this when we fix collection event leaks + if (submenuRef.current?.contains(e.target as Element)) { + e.stopPropagation(); + onSubmenuClose(); + if (!shouldUseVirtualFocus && ref.current) { + focusWithoutScrolling(ref.current); + } + } break; } }; @@ -134,12 +149,13 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm case 'ArrowRight': if (!isDisabled) { if (direction === 'ltr') { + e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) { - submenuRef.current.focus(); + focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { onSubmenuClose(); @@ -152,12 +168,13 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm case 'ArrowLeft': if (!isDisabled) { if (direction === 'rtl') { + e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) { - submenuRef.current.focus(); + focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { onSubmenuClose(); @@ -166,9 +183,6 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } } break; - case 'Escape': - state.closeAll(); - break; default: e.continuePropagation(); break; @@ -205,7 +219,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm }; let onBlur = (e) => { - if (state.isOpen && parentMenuRef.current?.contains(e.relatedTarget)) { + if (state.isOpen && (parentMenuRef.current?.contains(e.relatedTarget))) { onSubmenuClose(); } }; @@ -236,7 +250,10 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm submenuProps, popoverProps: { isNonModal: true, - disableFocusManagement: true, + // We will manually coerce focus back to the triggers for mobile screen readers and non virtual focus use cases (aka submenus outside of autocomplete) so turn off + // FocusScope then. For virtual focus use cases (Autocomplete subdialogs/menu) and subdialogs we want to keep FocusScope restoreFocus to automatically + // send focus to parent subdialog input fields and/or tab containment + disableFocusManagement: !shouldUseVirtualFocus && (getInteractionModality() === 'virtual' || type === 'menu'), shouldCloseOnInteractOutside } }; diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 31be6884dda..91a489876b4 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -14,6 +14,8 @@ // subtracted from when showing it again. When it reaches zero, aria-hidden is removed. let refCountMap = new WeakMap(); interface ObserverWrapper { + visibleNodes: Set, + hiddenNodes: Set, observe: () => void, disconnect: () => void } @@ -138,6 +140,8 @@ export function ariaHideOutside(targets: Element[], root = document.body) { observer.observe(root, {childList: true, subtree: true}); let observerWrapper: ObserverWrapper = { + visibleNodes, + hiddenNodes, observe() { observer.observe(root, {childList: true, subtree: true}); }, @@ -175,3 +179,13 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }; } + +export function keepVisible(element: Element) { + let observer = observerStack[observerStack.length - 1]; + if (observer && !observer.visibleNodes.has(element)) { + observer.visibleNodes.add(element); + return () => { + observer.visibleNodes.delete(element); + }; + } +} diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 2ebeeabad4a..8fdcfa39410 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -72,16 +72,15 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (isOpen) { + if (isOpen && !visibleOverlays.includes(ref)) { visibleOverlays.push(ref); + return () => { + let index = visibleOverlays.indexOf(ref); + if (index >= 0) { + visibleOverlays.splice(index, 1); + } + }; } - - return () => { - let index = visibleOverlays.indexOf(ref); - if (index >= 0) { - visibleOverlays.splice(index, 1); - } - }; }, [isOpen, ref]); // Only hide the overlay when it is the topmost visible overlay in the stack diff --git a/packages/@react-aria/overlays/src/usePopover.ts b/packages/@react-aria/overlays/src/usePopover.ts index 903af187fae..eeeead7b334 100644 --- a/packages/@react-aria/overlays/src/usePopover.ts +++ b/packages/@react-aria/overlays/src/usePopover.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {ariaHideOutside} from './ariaHideOutside'; +import {ariaHideOutside, keepVisible} from './ariaHideOutside'; import {AriaPositionProps, useOverlayPosition} from './useOverlayPosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {mergeProps, useLayoutEffect} from '@react-aria/utils'; @@ -28,6 +28,12 @@ export interface AriaPopoverProps extends Omit, + /** + * An optional ref for a group of popovers, e.g. submenus. + * When provided, this element is used to detect outside interactions + * and hiding elements from assistive technologies instead of the popoverRef. + */ + groupRef?: RefObject, /** * Whether the popover is non-modal, i.e. elements outside the popover may be * interacted with by assistive technologies. @@ -74,23 +80,25 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): let { triggerRef, popoverRef, + groupRef, isNonModal, isKeyboardDismissDisabled, shouldCloseOnInteractOutside, ...otherProps } = props; + let isSubmenu = otherProps['trigger'] === 'SubmenuTrigger' || otherProps['trigger'] === 'SubDialogTrigger'; + let {overlayProps, underlayProps} = useOverlay( { - // If popover is in the top layer, it should not prevent other popovers from being dismissed. - isOpen: state.isOpen && !otherProps['data-react-aria-top-layer'], + isOpen: state.isOpen, onClose: state.close, shouldCloseOnBlur: true, - isDismissable: !isNonModal, + isDismissable: !isNonModal || isSubmenu, isKeyboardDismissDisabled, shouldCloseOnInteractOutside }, - popoverRef + groupRef ?? popoverRef ); let {overlayProps: positionProps, arrowProps, placement} = useOverlayPosition({ @@ -106,10 +114,14 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): }); useLayoutEffect(() => { - if (state.isOpen && !isNonModal && popoverRef.current) { - return ariaHideOutside([popoverRef.current]); + if (state.isOpen && popoverRef.current) { + if (isNonModal) { + return keepVisible(groupRef?.current ?? popoverRef.current); + } else { + return ariaHideOutside([groupRef?.current ?? popoverRef.current]); + } } - }, [isNonModal, state.isOpen, popoverRef]); + }, [isNonModal, state.isOpen, popoverRef, groupRef]); return { popoverProps: mergeProps(overlayProps, positionProps), diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 5b36311f336..ef9c14e252c 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,12 +10,12 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; import {focusSafely, getInteractionModality} from '@react-aria/interactions'; -import {getFocusableTreeWalker} from '@react-aria/focus'; +import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {isNonContiguousSelectionModifier} from './utils'; import {MultipleSelectionManager} from '@react-stately/selection'; import {useLocale} from '@react-aria/i18n'; @@ -371,7 +371,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement; if (element) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!element.contains(document.activeElement)) { + if (!element.contains(document.activeElement) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } @@ -413,12 +413,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist if (keyToFocus == null) { - ref.current?.dispatchEvent( - new CustomEvent(UPDATE_ACTIVEDESCENDANT, { - cancelable: true, - bubbles: true - }) - ); + moveVirtualFocus(ref.current); // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. @@ -454,10 +449,12 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions resetFocusFirstFlag(); }, [manager.focusedKey, resetFocusFirstFlag]); - useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e) => { + useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => { e.stopPropagation(); manager.setFocused(false); - manager.setFocusedKey(null); + if (e.detail?.clearFocusKey) { + manager.setFocusedKey(null); + } }); const autoFocusRef = useRef(autoFocus); @@ -558,8 +555,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let tabIndex: number | undefined = undefined; if (!shouldUseVirtualFocus) { tabIndex = manager.focusedKey == null ? 0 : -1; - } else { - tabIndex = -1; } return { diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 690e1a51e8d..fe41c03d307 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -12,8 +12,9 @@ import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared'; import {focusSafely, PressProps, useLongPress, usePress} from '@react-aria/interactions'; -import {isCtrlKeyPressed, mergeProps, openLink, UPDATE_ACTIVEDESCENDANT, useId, useRouter} from '@react-aria/utils'; +import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {isNonContiguousSelectionModifier} from './utils'; +import {moveVirtualFocus} from '@react-aria/focus'; import {MultipleSelectionManager} from '@react-stately/selection'; import {useEffect, useRef} from 'react'; @@ -160,7 +161,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte // Focus the associated DOM node when this item becomes the focusedKey // TODO: can't make this useLayoutEffect bacause it breaks menus inside dialogs - // However, if this is a useEffect, it runs twice and dispatches two UPDATE_ACTIVEDESCENDANT and immediately sets + // However, if this is a useEffect, it runs twice and dispatches two blur events and immediately sets // aria-activeDescendant in useAutocomplete... I've worked around this for now useEffect(() => { let isFocused = key === manager.focusedKey; @@ -172,12 +173,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte focusSafely(ref.current); } } else { - let updateActiveDescendant = new CustomEvent(UPDATE_ACTIVEDESCENDANT, { - cancelable: true, - bubbles: true - }); - - ref.current?.dispatchEvent(updateActiveDescendant); + moveVirtualFocus(ref.current); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -321,6 +317,25 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte itemProps['data-key'] = key; itemPressProps.preventFocusOnPress = shouldUseVirtualFocus; + + // When using virtual focus, make sure the focused key gets updated on press. + if (shouldUseVirtualFocus) { + itemPressProps = mergeProps(itemPressProps, { + onPressStart(e) { + if (e.pointerType !== 'touch') { + manager.setFocused(true); + manager.setFocusedKey(key); + } + }, + onPress(e) { + if (e.pointerType === 'touch') { + manager.setFocused(true); + manager.setFocusedKey(key); + } + } + }); + } + let {pressProps, isPressed} = usePress(itemPressProps); // Double clicking with a mouse with selectionBehavior = 'replace' performs an action. @@ -366,9 +381,11 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte return { itemProps: mergeProps( itemProps, - allowsSelection || hasPrimaryAction ? pressProps : {}, + allowsSelection || hasPrimaryAction || shouldUseVirtualFocus ? pressProps : {}, longPressEnabled ? longPressProps : {}, - {onDoubleClick, onDragStartCapture, onClick, id} + {onDoubleClick, onDragStartCapture, onClick, id}, + // Prevent DOM focus from moving on mouse down when using virtual focus + shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined ), isPressed, isSelected: manager.isSelected(key), diff --git a/packages/@react-aria/textfield/src/useTextField.ts b/packages/@react-aria/textfield/src/useTextField.ts index a4002d937cc..824027d9fac 100644 --- a/packages/@react-aria/textfield/src/useTextField.ts +++ b/packages/@react-aria/textfield/src/useTextField.ts @@ -11,7 +11,9 @@ */ import {AriaTextFieldProps} from '@react-types/textfield'; -import { +import {DOMAttributes, ValidationResult} from '@react-types/shared'; +import {filterDOMProps, getOwnerWindow, mergeProps, useFormReset} from '@react-aria/utils'; +import React, { ChangeEvent, HTMLAttributes, type JSX, @@ -19,8 +21,6 @@ import { RefObject, useEffect } from 'react'; -import {DOMAttributes, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, getOwnerWindow, mergeProps, useFormReset} from '@react-aria/utils'; import {useControlledState} from '@react-stately/utils'; import {useField} from '@react-aria/label'; import {useFocusable} from '@react-aria/interactions'; @@ -186,6 +186,7 @@ export function useTextField= 17 ? 'enterKeyHint' : 'enterkeyhint']: props.enterKeyHint, // Clipboard events onCopy: props.onCopy, diff --git a/packages/@react-aria/utils/src/constants.ts b/packages/@react-aria/utils/src/constants.ts index 1f9250c3280..665779cf30c 100644 --- a/packages/@react-aria/utils/src/constants.ts +++ b/packages/@react-aria/utils/src/constants.ts @@ -13,4 +13,3 @@ // Custom event names for updating the autocomplete's aria-activedecendant. export const CLEAR_FOCUS_EVENT = 'react-aria-clear-focus'; export const FOCUS_EVENT = 'react-aria-focus'; -export const UPDATE_ACTIVEDESCENDANT = 'react-aria-update-activedescendant'; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 413e1f46639..9db58b2ee24 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -46,7 +46,7 @@ export {useDeepMemo} from './useDeepMemo'; export {useFormReset} from './useFormReset'; export {useLoadMore} from './useLoadMore'; export {inertValue} from './inertValue'; -export {CLEAR_FOCUS_EVENT, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants'; +export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants'; export {isCtrlKeyPressed} from './keyboard'; export {useEnterAnimation, useExitAnimation} from './animation'; export {isFocusable, isTabbable} from './isFocusable'; diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 94a04c8c3a3..553c7c38f6c 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -118,7 +118,7 @@ describe('SearchAutocomplete', function () { expect(items[1]).toHaveTextContent('Two'); expect(items[2]).toHaveTextContent('Three'); - expect(listbox).toHaveAttribute('tabIndex', '-1'); + expect(listbox).not.toHaveAttribute('tabIndex'); for (let item of items) { expect(item).not.toHaveAttribute('tabIndex'); } diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index ebdb76e8f54..4b97d4c810d 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -227,7 +227,7 @@ describe('ComboBox', function () { expect(items[1]).toHaveTextContent('Two'); expect(items[2]).toHaveTextContent('Three'); - expect(listbox).toHaveAttribute('tabIndex', '-1'); + expect(listbox).not.toHaveAttribute('tabIndex'); for (let item of items) { expect(item).not.toHaveAttribute('tabIndex'); } diff --git a/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx b/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx index 050adc174f3..0439f285d00 100644 --- a/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx +++ b/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx @@ -215,7 +215,7 @@ describe('Submenu', function () { act(() => {jest.runAllTimers();}); menus = tree.getAllByRole('menu', {hidden: true}); expect(menus).toHaveLength(2); - expect(document.activeElement).toBe(submenuTrigger1); + expect(document.activeElement).toBe(submenu1Items[0]); }); it('should close the sub menu if the user hovers a neighboring menu item from the submenu trigger', async function () { @@ -348,7 +348,6 @@ describe('Submenu', function () { ${'ltr, Enter/Esc'} | ${'en-US'} | ${[async () => await user.keyboard('[Enter]'), async () => await user.keyboard('[Escape]')]} `('opens/closes the submenu via keyboard ($Name)', async function ({Name, locale, actions}) { let tree = render(, 'medium', locale); - let triggerButton = tree.getByRole('button'); await user.tab(); await user.keyboard('[ArrowDown]'); act(() => {jest.runAllTimers();}); @@ -376,13 +375,6 @@ describe('Submenu', function () { act(() => {jest.runAllTimers();}); if (Name === 'ltr, Enter/Esc') { - // Closes all submenus + menu via Esc - menus = tree.queryAllByRole('menu', {hidden: true}); - expect(menus).toHaveLength(0); - expect(triggerButton).toHaveAttribute('aria-expanded', 'false'); - expect(onOpenChange).toHaveBeenCalledTimes(2); - expect(onOpenChange).toHaveBeenLastCalledWith(false); - } else { // Only closes the current submenu via Arrow keys menus = tree.getAllByRole('menu', {hidden: true}); expect(menus).toHaveLength(1); @@ -601,7 +593,7 @@ describe('Submenu', function () { await user.keyboard('[Escape]'); act(() => {jest.runAllTimers();}); menus = tree.queryAllByRole('menu'); - expect(menus).toHaveLength(0); + expect(menus).toHaveLength(1); expect(onClose).not.toHaveBeenCalled(); expect(submenuOnClose).not.toHaveBeenCalled(); }); diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index f95e3d46236..140fc81b153 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -125,7 +125,7 @@ "@react-aria/test-utils": "1.0.0-alpha.3", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^15.0.7", - "@testing-library/user-event": "^14.0.0", + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", "jest": "^29.5.0" }, "dependencies": { diff --git a/packages/@react-stately/list/src/index.ts b/packages/@react-stately/list/src/index.ts index 75fa884a428..c70545cd5b9 100644 --- a/packages/@react-stately/list/src/index.ts +++ b/packages/@react-stately/list/src/index.ts @@ -12,6 +12,6 @@ export type {ListProps, ListState} from './useListState'; export type {SingleSelectListProps, SingleSelectListState} from './useSingleSelectListState'; -export {useListState} from './useListState'; +export {useListState, useFilteredListState} from './useListState'; export {useSingleSelectListState} from './useSingleSelectListState'; export {ListCollection} from './ListCollection'; diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index 7135ddd517b..513909369f8 100644 --- a/packages/@react-stately/list/src/useListState.ts +++ b/packages/@react-stately/list/src/useListState.ts @@ -61,11 +61,35 @@ export function useListState(props: ListProps): ListState(state: ListState, filterFn: ((nodeValue: string) => boolean) | null | undefined): ListState { + let collection = useMemo(() => filterFn ? state.collection.filter!(filterFn) : state.collection, [state.collection, filterFn]); + let selectionManager = state.selectionManager.withCollection(collection); + useFocusedKeyReset(collection, selectionManager); + return { + collection, + selectionManager, + disabledKeys: state.disabledKeys + }; +} + +function useFocusedKeyReset(collection: Collection>, selectionManager: SelectionManager) { // Reset focused key if that item is deleted from the collection. const cachedCollection = useRef> | null>(null); useEffect(() => { - if (selectionState.focusedKey != null && !collection.getItem(selectionState.focusedKey) && cachedCollection.current) { - const startItem = cachedCollection.current.getItem(selectionState.focusedKey); + if (selectionManager.focusedKey != null && !collection.getItem(selectionManager.focusedKey) && cachedCollection.current) { + const startItem = cachedCollection.current.getItem(selectionManager.focusedKey); const cachedItemNodes = [...cachedCollection.current.getKeys()].map( key => { const itemNode = cachedCollection.current!.getItem(key); @@ -105,14 +129,8 @@ export function useListState(props: ListProps): ListState>) { + return new SelectionManager(collection, this.state, { + allowsCellSelection: this.allowsCellSelection, + layoutDelegate: this.layoutDelegate || undefined + }); + } } diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index f627cdf3511..b5d3dee3f7e 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -180,7 +180,10 @@ export interface Collection extends Iterable { getChildren?(key: Key): Iterable, /** Returns a string representation of the item's contents. */ - getTextValue?(key: Key): string + getTextValue?(key: Key): string, + + /** Filters the collection using the given function. */ + filter?(filterFn: (nodeValue: string) => boolean): Collection } export interface Node { diff --git a/packages/@react-types/shared/src/dom.d.ts b/packages/@react-types/shared/src/dom.d.ts index d6acd30ba68..327c9b2a41a 100644 --- a/packages/@react-types/shared/src/dom.d.ts +++ b/packages/@react-types/shared/src/dom.d.ts @@ -176,7 +176,12 @@ export interface TextInputDOMProps extends DOMProps, InputDOMProps, TextInputDOM /** * An enumerated attribute that defines whether the element may be checked for spelling errors. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck). */ - spellCheck?: string + spellCheck?: string, + + /** + * An enumerated attribute that defines what action label or icon to preset for the enter key on virtual keyboards. See [https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint]. + */ + enterKeyHint?: string } /** diff --git a/packages/dev/test-utils/package.json b/packages/dev/test-utils/package.json index 949ccaf1ee4..592fb46cd71 100644 --- a/packages/dev/test-utils/package.json +++ b/packages/dev/test-utils/package.json @@ -27,7 +27,7 @@ "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^15.0.7", - "@testing-library/user-event": "^14.4.3", + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", "jest": "^29.5.0", "resolve": "^1.17.0" }, diff --git a/packages/react-aria-components/example/index.css b/packages/react-aria-components/example/index.css index 1e65e4996f8..3a56707c261 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -466,3 +466,11 @@ html { padding-bottom: 10px; } } + +input { + outline: none; +} + +[aria-autocomplete][data-focus-visible]{ + outline: 3px solid blue; +} diff --git a/packages/react-aria-components/src/Autocomplete.tsx b/packages/react-aria-components/src/Autocomplete.tsx index e765a454ca7..1dfa6fcdaeb 100644 --- a/packages/react-aria-components/src/Autocomplete.tsx +++ b/packages/react-aria-components/src/Autocomplete.tsx @@ -12,6 +12,7 @@ import {AriaAutocompleteProps, CollectionOptions, UNSTABLE_useAutocomplete} from '@react-aria/autocomplete'; import {AutocompleteState, UNSTABLE_useAutocompleteState} from '@react-stately/autocomplete'; +import {InputContext} from './Input'; import {mergeProps} from '@react-aria/utils'; import {Provider, removeDataAttributes, SlotProps, SlottedContextValue, useSlottedContext} from './utils'; import React, {createContext, RefObject, useRef} from 'react'; @@ -40,8 +41,8 @@ export function UNSTABLE_Autocomplete(props: AutocompleteProps) { props = mergeProps(ctx, props); let {filter} = props; let state = UNSTABLE_useAutocompleteState(props); + let inputRef = useRef(null); let collectionRef = useRef(null); - let { textFieldProps, collectionProps, @@ -50,6 +51,7 @@ export function UNSTABLE_Autocomplete(props: AutocompleteProps) { } = UNSTABLE_useAutocomplete({ ...removeDataAttributes(props), filter, + inputRef, collectionRef }, state); @@ -59,6 +61,7 @@ export function UNSTABLE_Autocomplete(props: AutocompleteProps) { [UNSTABLE_AutocompleteStateContext, state], [SearchFieldContext, textFieldProps], [TextFieldContext, textFieldProps], + [InputContext, {ref: inputRef}], [UNSTABLE_InternalAutocompleteContext, { filterFn, collectionProps, diff --git a/packages/react-aria-components/src/Group.tsx b/packages/react-aria-components/src/Group.tsx index 749dc0b40be..222ca6a0037 100644 --- a/packages/react-aria-components/src/Group.tsx +++ b/packages/react-aria-components/src/Group.tsx @@ -74,6 +74,7 @@ export const Group = /*#__PURE__*/ (forwardRef as forwardRefType)(function Group isDisabled ??= !!props['aria-disabled'] && props['aria-disabled'] !== 'false'; isInvalid ??= !!props['aria-invalid'] && props['aria-invalid'] !== 'false'; + let renderProps = useRenderProps({ ...props, values: {isHovered, isFocusWithin: isFocused, isFocusVisible, isDisabled, isInvalid}, diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 926f53bfd98..b3b02a52c79 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -16,7 +16,7 @@ import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionCont import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils'; import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; -import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, useListState} from 'react-stately'; +import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, useFilteredListState, useListState} from 'react-stately'; import {filterDOMProps, mergeRefs, useObjectRef} from '@react-aria/utils'; import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; @@ -107,12 +107,8 @@ export const ListBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Lis function StandaloneListBox({props, listBoxRef, collection}) { props = {...props, collection, children: null, items: null}; let {layoutDelegate} = useContext(CollectionRendererContext); - let {filterFn, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; - // Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens - listBoxRef = useObjectRef(useMemo(() => mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, listBoxRef])); - let filteredCollection = useMemo(() => filterFn ? collection.filter(filterFn) : collection, [collection, filterFn]); - let state = useListState({...props, collection: filteredCollection, layoutDelegate}); - return ; + let state = useListState({...props, layoutDelegate}); + return ; } interface ListBoxInnerProps { @@ -121,8 +117,13 @@ interface ListBoxInnerProps { listBoxRef: RefObject } -function ListBoxInner({state, props, listBoxRef}: ListBoxInnerProps) { +function ListBoxInner({state: inputState, props, listBoxRef}: ListBoxInnerProps) { + let {filterFn, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {}; + props = useMemo(() => collectionProps ? ({...props, ...collectionProps}) : props, [props, collectionProps]); let {dragAndDropHooks, layout = 'stack', orientation = 'vertical'} = props; + // Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens + listBoxRef = useObjectRef(useMemo(() => mergeRefs(listBoxRef, collectionRef !== undefined ? collectionRef as RefObject : null), [collectionRef, listBoxRef])); + let state = useFilteredListState(inputState, filterFn); let {collection, selectionManager} = state; let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState; let isListDroppable = !!dragAndDropHooks?.useDroppableCollectionState; diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 502cacc2bc2..7c5c9e6c57a 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -15,12 +15,12 @@ import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, cr import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately'; import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; +import {DialogContext, OverlayTriggerStateContext} from './Dialog'; import {filterDOMProps, mergeRefs, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {FocusStrategy, forwardRefType, HoverEvents, Key, LinkDOMProps, MultipleSelection} from '@react-types/shared'; import {HeaderContext} from './Header'; import {KeyboardContext} from './Keyboard'; import {MultipleSelectionState, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; -import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext} from './Popover'; import {PressResponder, useHover} from '@react-aria/interactions'; import React, { @@ -70,7 +70,6 @@ export function MenuTrigger(props: MenuTriggerProps) { ref: ref, onResize: onResize }); - let scrollRef = useRef(null); return ( @@ -106,7 +105,7 @@ export interface SubmenuTriggerProps { delay?: number } -const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject} | null>(null); +const SubmenuTriggerContext = createContext<{parentMenuRef: RefObject, shouldUseVirtualFocus?: boolean} | null>(null); /** * A submenu trigger is used to wrap a submenu's trigger item and the submenu itself. @@ -120,11 +119,12 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigg let submenuTriggerState = useSubmenuTriggerState({triggerKey: item.key}, rootMenuTriggerState); let submenuRef = useRef(null); let itemRef = useObjectRef(ref); - let {parentMenuRef} = useContext(SubmenuTriggerContext)!; + let {parentMenuRef, shouldUseVirtualFocus} = useContext(SubmenuTriggerContext)!; let {submenuTriggerProps, submenuProps, popoverProps} = useSubmenuTrigger({ parentMenuRef, submenuRef, - delay: props.delay + delay: props.delay, + shouldUseVirtualFocus }, submenuTriggerState, itemRef); return ( @@ -138,9 +138,62 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent('submenutrigg trigger: 'SubmenuTrigger', triggerRef: itemRef, placement: 'end top', - // Prevent parent popover from hiding submenu. - // @ts-ignore - 'data-react-aria-top-layer': true, + ...popoverProps + }] + ]}> + + {props.children[1]} + + ); +}, props => props.children[0]); + +// TODO: make SubdialogTrigger unstable +export interface SubDialogTriggerProps { + /** + * The contents of the SubDialogTrigger. The first child should be an Item (the trigger) and the second child should be the Popover (for the subdialog). + */ + children: ReactElement[], + /** + * The delay time in milliseconds for the subdialog to appear after hovering over the trigger. + * @default 200 + */ + delay?: number +} + +/** + * A subdialog trigger is used to wrap a subdialog's trigger item and the subdialog itself. + * + * @version alpha + */ +export const SubDialogTrigger = /*#__PURE__*/ createBranchComponent('subdialogtrigger', (props: SubDialogTriggerProps, ref: ForwardedRef, item) => { + let {CollectionBranch} = useContext(CollectionRendererContext); + let state = useContext(MenuStateContext)!; + let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; + let submenuTriggerState = useSubmenuTriggerState({triggerKey: item.key}, rootMenuTriggerState); + let subdialogRef = useRef(null); + let itemRef = useObjectRef(ref); + let {parentMenuRef, shouldUseVirtualFocus} = useContext(SubmenuTriggerContext)!; + let {submenuTriggerProps, submenuProps, popoverProps} = useSubmenuTrigger({ + parentMenuRef, + submenuRef: subdialogRef, + type: 'dialog', + delay: props.delay, + shouldUseVirtualFocus + // TODO: might need to have something like isUnavailable like we do for ContextualHelpTrigger + }, submenuTriggerState, itemRef); + + return ( + @@ -206,9 +259,14 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [MenuStateContext, state], [SeparatorContext, {elementType: 'div'}], [SectionContext, {name: 'MenuSection', render: MenuSectionInner}], - [SubmenuTriggerContext, {parentMenuRef: ref}], + [SubmenuTriggerContext, {parentMenuRef: ref, shouldUseVirtualFocus: autocompleteMenuProps?.shouldUseVirtualFocus}], [MenuItemContext, null], - [SelectionManagerContext, state.selectionManager] + [UNSTABLE_InternalAutocompleteContext, null], + [SelectionManagerContext, state.selectionManager], + /* Ensure root MenuTriggerState is defined, in case Menu is rendered outside a MenuTrigger. */ + /* We assume the context can never change between defined and undefined. */ + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + [RootMenuTriggerStateContext, triggerState ?? useMenuTriggerState({})] ]}> , Omit, OverlayTriggerProps, RenderProps, SlotProps { +export interface PopoverProps extends Omit, Omit, OverlayTriggerProps, RenderProps, SlotProps { /** * The name of the component that triggered the popover. This is reflected on the element * as the `data-trigger` attribute, and can be used to provide specific @@ -80,6 +80,9 @@ export interface PopoverRenderProps { export const PopoverContext = createContext>(null); +// Stores a ref for the portal container for a group of popovers (e.g. submenus). +const PopoverGroupContext = createContext | null>(null); + /** * A popover is an overlay element positioned relative to a trigger. */ @@ -137,6 +140,9 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po // Referenced from: packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx let arrowRef = useRef(null); let [arrowWidth, setArrowWidth] = useState(0); + let containerRef = useRef(null); + let groupCtx = useContext(PopoverGroupContext); + let isSubPopover = groupCtx && (props.trigger === 'SubmenuTrigger' || props.trigger === 'SubDialogTrigger'); useLayoutEffect(() => { if (arrowRef.current && state.isOpen) { setArrowWidth(arrowRef.current.getBoundingClientRect().width); @@ -146,7 +152,10 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po let {popoverProps, underlayProps, arrowProps, placement} = usePopover({ ...props, offset: props.offset ?? 8, - arrowSize: arrowWidth + arrowSize: arrowWidth, + // If this is a submenu/subdialog, use the root popover's container + // to detect outside interaction and add aria-hidden. + groupRef: isSubPopover ? groupCtx! : containerRef }, state); let ref = props.popoverRef as RefObject; @@ -163,27 +172,44 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po }); let style = {...popoverProps.style, ...renderProps.style}; + let overlay = ( +
+ {!props.isNonModal && } + + {renderProps.children} + + +
+ ); + + // If this is a root popover, render an extra div to act as the portal container for submenus/subdialogs. + if (!isSubPopover) { + return ( + + {!props.isNonModal && state.isOpen &&
} +
+ + {overlay} + +
+ + ); + } + // Submenus/subdialogs are mounted into the root popover's container. return ( - - {!props.isNonModal && state.isOpen &&
} -
- {!props.isNonModal && } - - {renderProps.children} - - -
+ + {overlay} ); } diff --git a/packages/react-aria-components/src/SearchField.tsx b/packages/react-aria-components/src/SearchField.tsx index a01b62588e2..29d322f65cf 100644 --- a/packages/react-aria-components/src/SearchField.tsx +++ b/packages/react-aria-components/src/SearchField.tsx @@ -13,14 +13,14 @@ import {AriaSearchFieldProps, useSearchField} from 'react-aria'; import {ButtonContext} from './Button'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; +import {createHideableComponent} from '@react-aria/collections'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; import {FormContext} from './Form'; -import {forwardRefType} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react'; +import React, {createContext, ForwardedRef, useRef} from 'react'; import {SearchFieldState, useSearchFieldState} from 'react-stately'; import {TextContext} from './Text'; @@ -53,7 +53,7 @@ export const SearchFieldContext = createContext) { +export const SearchField = /*#__PURE__*/ createHideableComponent(function SearchField(props: SearchFieldProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, SearchFieldContext); let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; diff --git a/packages/react-aria-components/src/TextField.tsx b/packages/react-aria-components/src/TextField.tsx index 9f98bc8c0da..066959780dd 100644 --- a/packages/react-aria-components/src/TextField.tsx +++ b/packages/react-aria-components/src/TextField.tsx @@ -12,13 +12,13 @@ import {AriaTextFieldProps, useTextField} from 'react-aria'; import {ContextValue, DOMProps, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; +import {createHideableComponent} from '@react-aria/collections'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; import {FormContext} from './Form'; -import {forwardRefType} from '@react-types/shared'; import {InputContext} from './Input'; import {LabelContext} from './Label'; -import React, {createContext, ForwardedRef, forwardRef, useCallback, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, useCallback, useRef, useState} from 'react'; import {TextAreaContext} from './TextArea'; import {TextContext} from './Text'; @@ -55,7 +55,7 @@ export const TextFieldContext = createContext) { +export const TextField = /*#__PURE__*/ createHideableComponent(function TextField(props: TextFieldProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, TextFieldContext); let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 6b96789d5f4..fd160e3f9e7 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -50,7 +50,7 @@ export {Keyboard, KeyboardContext} from './Keyboard'; export {Label, LabelContext} from './Label'; export {Link, LinkContext} from './Link'; export {ListBox, ListBoxItem, ListBoxSection, ListBoxContext, ListStateContext} from './ListBox'; -export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger} from './Menu'; +export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger, SubDialogTrigger as UNSTABLE_SubDialogTrigger} from './Menu'; export {Meter, MeterContext} from './Meter'; export {Modal, ModalOverlay, ModalContext} from './Modal'; export {NumberField, NumberFieldContext, NumberFieldStateContext} from './NumberField'; diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 88464aa8ba0..38aea91fd8e 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {UNSTABLE_Autocomplete as Autocomplete, Button, Collection, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Separator, Text, TextField, Virtualizer} from 'react-aria-components'; +import {UNSTABLE_Autocomplete as Autocomplete, Button, Collection, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Select, SelectValue, Separator, UNSTABLE_SubDialogTrigger as SubDialogTrigger, SubmenuTrigger, Text, TextField, Virtualizer} from 'react-aria-components'; import {MyListBoxItem, MyMenuItem} from './utils'; import React, {useMemo} from 'react'; import styles from '../example/index.css'; @@ -50,6 +50,31 @@ let StaticMenu = (props) => { Bar Baz Google + + With subdialog + + + + + + + Please select an option below. + + + Subdialog Foo + Subdialog Bar + Subdialog Baz + + + + + Option Option with a space @@ -89,7 +114,7 @@ export const AutocompleteExample = { let {onAction, onSelectionChange, selectionMode} = args; return ( - +
@@ -120,32 +145,172 @@ export const AutocompleteSearchfield = { ); }, - name: 'Autocomplete complex static with searchfield' + name: 'Autocomplete complex static with searchfield', + parameters: { + description: { + data: 'Note that on mobile, trying to type into the subdialog inputs may cause scrolling and thus cause the subdialog to close. Please test in landscape mode.' + } + } }; -interface AutocompleteItem { - id: string, - name: string +// Note that the trigger items in this array MUST have an id, even if the underlying MenuItem might apply its own +// id. If it is omitted, we can't build the collection node for the trigger node and an error will throw +let dynamicAutocompleteSubdialog = [ + {name: 'Section 1', isSection: true, children: [ + {name: 'Command Palette'}, + {name: 'Open View'} + ]}, + {name: 'Section 2', isSection: true, children: [ + {name: 'Appearance', id: 'appearance', children: [ + {name: 'Sub Section 1', isSection: true, children: [ + {name: 'Move Primary Side Bar Right'}, + {name: 'Activity Bar Position', id: 'activity', isMenu: true, children: [ + {name: 'Default'}, + {name: 'Top'}, + {name: 'Bottom'}, + {name: 'Hidden'}, + {name: 'Subdialog test', id: 'sub', children: [ + {name: 'A'}, + {name: 'B'}, + {name: 'C'}, + {name: 'D'} + ]}, + {name: 'Submenu test', id: 'sub2', isMenu: true, children: [ + {name: 'A'}, + {name: 'B'}, + {name: 'C'}, + {name: 'D'} + ]} + ]}, + {name: 'Panel Position', id: 'position', children: [ + {name: 'Top'}, + {name: 'Left'}, + {name: 'Right'}, + {name: 'Bottom'} + ]} + ]} + ]}, + {name: 'Editor Layout', id: 'editor', children: [ + {name: 'Sub Section 1', isSection: true, children: [ + {name: 'Split up'}, + {name: 'Split down'}, + {name: 'Split left'}, + {name: 'Split right'} + ]}, + {name: 'Sub Section 2', isSection: true, children: [ + {name: 'Single'}, + {name: 'Two columns'}, + {name: 'Three columns'}, + {name: 'Two rows'}, + {name: 'Three rows'} + ]} + ]} + ]} +]; + +interface ItemNode { + name?: string, + textValue?: string, + isSection?: boolean, + isMenu?: boolean, + children?: ItemNode[] } -let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; +let dynamicRenderTrigger = (item: ItemNode) => { + if (item.isMenu) { + return ( + + {item.name} + + + {(item) => dynamicRenderFuncSections(item)} + + + + ); + } else { + return ( + + + {item.name} + + + + + + + + Please select an option below. + + + {(item) => dynamicRenderFuncSections(item)} + + + + + + ); + } +}; + +let dynamicRenderItem = (item) => ( + + {item.name} + +); + +let dynamicRenderFuncSections = (item: ItemNode) => { + if (item.children) { + if (item.isSection) { + return ( + + {item.name != null &&
{item.name}
} + + {(item) => { + if (item.children) { + return dynamicRenderTrigger(item); + } else { + return dynamicRenderItem(item); + } + }} + +
+ ); + } else { + return dynamicRenderTrigger(item); + } + } else { + return dynamicRenderItem(item); + } +}; + export const AutocompleteMenuDynamic = { render: (args) => { let {onAction, onSelectionChange, selectionMode} = args; return ( - -
- - - - Please select an option below. - - - {item => {item.name}} - -
-
+ <> + + +
+ + + + Please select an option below. + + + {item => dynamicRenderFuncSections(item)} + +
+
+ + ); }, name: 'Autocomplete, dynamic menu' @@ -174,6 +339,13 @@ export const AutocompleteOnActionOnMenuItems = { name: 'Autocomplete, onAction on menu items' }; +interface AutocompleteItem { + id: string, + name: string +} + +let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, {id: '3', name: 'Baz'}]; + export const AutocompleteDisabledKeys = { render: (args) => { let {onAction, onSelectionChange, selectionMode} = args; @@ -598,3 +770,74 @@ export function AutocompleteWithExtraButtons() {
); } + +export const AutocompleteMenuInPopoverDialogTrigger = { + render: (args) => { + let {onAction, onSelectionChange, selectionMode} = args; + return ( + + + + + {() => ( + +
+ + + + Please select an option below. + + + {item => dynamicRenderFuncSections(item)} + +
+
+ )} +
+
+
+ ); + }, + name: 'Autocomplete in popover (dialog trigger), rendering dynamic autocomplete menu', + argTypes: { + selectionMode: { + table: { + disable: true + } + } + } +}; + +let manyItems = [...Array(100)].map((_, i) => ({id: i, name: `Item ${i}`})); + +export const AutocompleteSelect = () => ( + + + + {item => {item.name}} + + + + + +); diff --git a/packages/react-aria-components/stories/Menu.stories.tsx b/packages/react-aria-components/stories/Menu.stories.tsx index 6c19981c5fe..3614f05596c 100644 --- a/packages/react-aria-components/stories/Menu.stories.tsx +++ b/packages/react-aria-components/stories/Menu.stories.tsx @@ -11,10 +11,11 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Header, Keyboard, Menu, MenuSection, MenuTrigger, Popover, Separator, SubmenuTrigger, Text} from 'react-aria-components'; +import {Button, Dialog, Header, Heading, Input, Keyboard, Label, Menu, MenuSection, MenuTrigger, Popover, Separator, SubmenuTrigger, Text, TextField} from 'react-aria-components'; import {MyMenuItem} from './utils'; import React from 'react'; import styles from '../example/index.css'; +import {SubDialogTrigger} from '../src/Menu'; export default { title: 'React Aria Components' @@ -279,6 +280,97 @@ export const SubmenuSectionsExample = (args) => ( ); +// TODO: figure out why it is autofocusing the Menu in the SubDialog +export const SubdialogExample = (args) => ( + + + + + Foo + + Bar + + + {({close}) => ( +
+ Sign up + + + + + + + + + + + SubMenu + + + 1 + 2 + 3 + + + + + SubDialog + + + {({close}) => ( + + Contact + + + + + + + + + + + )} + + + + C +
+ + + )} +
+
+
+ Baz + Google +
+
+
+); + let submenuArgs = { args: { delay: 200 @@ -294,3 +386,4 @@ SubmenuExample.story = {...submenuArgs}; SubmenuNestedExample.story = {...submenuArgs}; SubmenuManyItemsExample.story = {...submenuArgs}; SubmenuDisabledExample.story = {...submenuArgs}; +SubdialogExample.story = {...submenuArgs}; diff --git a/packages/react-aria-components/stories/utils.tsx b/packages/react-aria-components/stories/utils.tsx index 6aac0492c0d..bc970c4b98e 100644 --- a/packages/react-aria-components/stories/utils.tsx +++ b/packages/react-aria-components/stories/utils.tsx @@ -8,10 +8,11 @@ export const MyListBoxItem = (props: ListBoxItemProps) => { classNames(styles, 'item', { + className={({isFocused, isSelected, isHovered, isFocusVisible}) => classNames(styles, 'item', { focused: isFocused, selected: isSelected, - hovered: isHovered + hovered: isHovered, + focusVisible: isFocusVisible })} /> ); }; diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index f378477e0f5..ebe042293e4 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {act, render, within} from '@testing-library/react'; +import {act, fireEvent, render, within} from '@testing-library/react'; import { AriaBaseTestProps, + installPointerEvent, mockClickDefault, pointerMap } from '@react-spectrum/test-utils-internal'; @@ -47,9 +48,16 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps { defaultValue?: () => ReturnType, // should allow the user to filter the items themselves in a async manner. The items should be Foo, Bar, and Baz with ids 1, 2, and 3 respectively. // The filtering can take any amount of time but should be standard non-case sensitive contains matching - asyncFiltering?: () => ReturnType - // TODO, add tests for this when we support it - // submenus?: (props?: {name: string}) => ReturnType + asyncFiltering?: () => ReturnType, + // Should have a menu with three items, and two levels of submenus. Tree should be roughly: Foo Bar Baz -> (branch off Bar) Lvl 1 Bar 1, Lvl 1 Bar 2, Lvl 1 Bar 3 -> + // (branch off Lvl 1 Bar 2) -> Lvl 2 Bar 1, Lvl 2 Bar 2, Lvl 2 Bar 3 + submenus?: () => ReturnType, + // Should have a menu with three items, and two levels of subdialog. Tree should be roughly: Foo Bar Baz -> (branch off Bar) Lvl 1 Bar 1, Lvl 1 Bar 2, Lvl 1 Bar 3 -> + // (branch off Lvl 1 Bar 2) -> Lvl 2 Bar 1, Lvl 2 Bar 2, Lvl 2 Bar 3 + subdialogs?: () => ReturnType, + // Should have a menu with items -> a subdialog -> submenu -> subdialog. Tree should be roughly: Foo Bar Baz -> (branch off Bar) Lvl 1 Bar 1, Lvl 1 Bar 2, Lvl 1 Bar 3 -> + // (branch off Lvl 1 Bar 2) -> Lvl 2 Bar 1, Lvl 2 Bar 2, Lvl 2 Bar 3 -> (branch off Lvl 2 Bar 2) -> Lvl 3 Bar 1, Lvl 3 Bar 2, Lvl 3 Bar 3 + subdialogAndMenu?: () => ReturnType }, ariaPattern?: 'menu' | 'listbox', selectionListener?: jest.Mock, @@ -119,7 +127,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(document.activeElement).toBe(input); }); - it('should clear the focused key when using ArrowLeft and ArrowRight', async function () { + it('should clear the focused key when using ArrowLeft and ArrowRight but preserves it internally for future keyboard operations', async function () { let {getByRole} = renderers.standard(); let input = getByRole('searchbox'); let menu = getByRole(collectionNodeRole); @@ -128,17 +136,45 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' await user.tab(); expect(document.activeElement).toBe(input); - await user.keyboard('Foo'); - act(() => jest.runAllTimers()); + await user.keyboard('{ArrowDown}'); let options = within(menu).getAllByRole(collectionItemRole); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); await user.keyboard('{ArrowRight}'); expect(input).not.toHaveAttribute('aria-activedescendant'); + // Old focused key was options[0] so should move one down await user.keyboard('{ArrowDown}'); - expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); await user.keyboard('{ArrowLeft}'); expect(input).not.toHaveAttribute('aria-activedescendant'); expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + }); + + it('should completely clear the focused key when Backspacing', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('B'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + let firstActiveDescendant = options[0].id; + expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); + expect(options[0]).toHaveTextContent('Bar'); + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + options = within(menu).getAllByRole(collectionItemRole); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(firstActiveDescendant).not.toEqual(options[0].id); + expect(options[0]).toHaveTextContent('Foo'); }); it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { @@ -205,6 +241,16 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(input.selectionStart).toBe(2); }); + it('should focus the input when clicking on an item', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox') as HTMLInputElement; + let menu = getByRole(collectionNodeRole); + let options = within(menu).getAllByRole(collectionItemRole); + + await user.click(options[0]); + expect(document.activeElement).toBe(input); + }); + if (ariaPattern === 'menu') { it('should update the aria-activedescendant when hovering over an item', async function () { let {getByRole} = renderers.standard(); @@ -496,33 +542,6 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' }); } - if (renderers.links) { - describe('with links', function () { - it('should trigger the link option when hitting Enter', async function () { - let {getByRole} = (renderers.links!)(); - let input = getByRole('searchbox'); - let menu = getByRole(collectionNodeRole); - expect(input).not.toHaveAttribute('aria-activedescendant'); - - await user.tab(); - expect(document.activeElement).toBe(input); - - await user.keyboard('{ArrowDown}'); - await user.keyboard('{ArrowDown}'); - await user.keyboard('{ArrowDown}'); - - let options = within(menu).getAllByRole(collectionItemRole); - expect(options[2].tagName).toBe('A'); - expect(options[2]).toHaveAttribute('href', 'https://google.com'); - let onClick = mockClickDefault(); - - await user.keyboard('{Enter}'); - expect(onClick).toHaveBeenCalledTimes(1); - window.removeEventListener('click', onClick); - }); - }); - } - if (renderers.sections) { describe('with sections', function () { it('should properly skip over sections when keyboard navigating', async function () { @@ -638,5 +657,447 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' }); }); } + + if (renderers.links) { + describe('with links', function () { + it('should trigger the link option when hitting Enter', async function () { + let {getByRole} = (renderers.links!)(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + + let options = within(menu).getAllByRole(collectionItemRole); + expect(options[2].tagName).toBe('A'); + expect(options[2]).toHaveAttribute('href', 'https://google.com'); + let onClick = mockClickDefault(); + + await user.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledTimes(1); + window.removeEventListener('click', onClick); + }); + }); + } + + if (renderers.submenus) { + // TODO: wrap all of these within a DialogTrigger? + describe('with submenus', function () { + it('should open a submenu when pressing the autocomplete wrapped submenu trigger', async function () { + let {getByRole, getAllByRole} = (renderers.submenus!)(); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); + + await user.click(options[1]); + act(() => { + jest.runAllTimers(); + }); + + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + }); + + describe('pointer events', function () { + installPointerEvent(); + + it('should close the menu when hovering an adjacent menu item in the virtual focus list', async function () { + let {getByRole, getAllByRole} = (renderers.submenus!)(); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); + await user.click(options[1]); + act(() => { + jest.runAllTimers(); + }); + + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + + await user.hover(options[2]); + act(() => { + jest.runAllTimers(); + }); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + }); + }); + + it('should not clear the focused key when using arrowRight to open a submenu', async function () { + let {getByRole, getAllByRole} = (renderers.submenus!)(); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); + expect(options[1]).toHaveAttribute('data-focused'); + expect(options[1]).toHaveAttribute('data-focus-visible'); + + // Open submenu + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(options[1]).not.toHaveAttribute('data-focused'); + expect(options[1]).not.toHaveAttribute('data-focus-visible'); + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + expect(menus[1]).toContainElement(document.activeElement as HTMLElement); + + // Close submenu and check that previous focus location was retained + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(options[1]).toHaveAttribute('data-focused'); + expect(options[1]).toHaveAttribute('data-focus-visible'); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[2].id); + expect(options[2]).toHaveAttribute('data-focused'); + expect(options[2]).toHaveAttribute('data-focus-visible'); + }); + + it('should only close a single level when hitting Escape and focus should be moved back to the input', async function () { + let {getByRole, getAllByRole} = (renderers.submenus!)(); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + // Open submenu + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + expect(menus[1]).toContainElement(document.activeElement as HTMLElement); + + // Open the nested submenu + await user.keyboard('{ArrowDown}'); + expect(document.activeElement).toHaveAttribute('aria-haspopup'); + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(3); + expect(menus[2]).toContainElement(document.activeElement as HTMLElement); + + // Close submenus and check that previous focus location are retained + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + expect(menus[1]).toContainElement(document.activeElement as HTMLElement); + expect(document.activeElement).toBe(within(menus[1]).getAllByRole('menuitem')[1]); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + expect(document.activeElement).toBe(input); + }); + + it('should close all menus when clicking on the body', async function () { + let {getByRole, getAllByRole} = (renderers.submenus!)(); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); + await user.click(options[1]); + act(() => { + jest.runAllTimers(); + }); + + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + + await user.hover(within(menus[1]).getAllByRole('menuitem')[1]); + act(() => { + jest.runAllTimers(); + }); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(3); + + await user.click(document.body); + act(() => { + jest.runAllTimers(); + }); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + }); + + // TODO: not sure why this is causing the "statndard interactions -> should support keyboard navigation" test to fail... + it.skip('should close the current submenu when clicking the dismiss button', function () { + let {getByRole, getAllByRole} = (renderers.submenus!)(); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + + act(() => input.focus()); + fireEvent.click(input, {pointerType: 'mouse', width: 1, height: 1, detail: 0}); + expect(document.activeElement).toBe(input); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); + + act(() => options[1].focus()); + fireEvent.click(options[1], {pointerType: 'mouse', width: 1, height: 1, detail: 0}); + act(() => jest.runAllTimers()); + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + + let popover = menus[1].closest('.react-aria-Popover'); + let dismissButtons = within(popover as HTMLElement).getAllByRole('button', {hidden: true}); + expect(dismissButtons.length).toBe(1); + act(() => dismissButtons[0].focus()); + fireEvent.click(dismissButtons[0], {pointerType: 'mouse', width: 1, height: 1, detail: 0}); + act(() => jest.runAllTimers()); + + menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + expect(document.activeElement).toBe(options[1]); + }); + }); + } + + if (renderers.subdialogs) { + describe('with subdialogs', function () { + it('should open a subdialog when pressing the autocomplete wrapped subdialog triggers', async function () { + let {getByRole, getAllByRole} = (renderers.subdialogs!)(); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + + await user.click(options[1]); + act(() => {jest.runAllTimers();}); + + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + + await user.click(within(dialogs[0]).getAllByRole('menuitem')[1]); + act(() => {jest.runAllTimers();}); + + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(2); + }); + + it('should close the subdialog when hovering an adjacent menu item in the virtual focus list', async function () { + document.elementFromPoint = jest.fn().mockImplementation(query => query); + let {getByRole, getAllByRole} = (renderers.subdialogs!)(); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + await user.click(options[1]); + act(() => { + jest.runAllTimers(); + }); + + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + + await user.hover(within(dialogs[0]).getAllByRole('menuitem')[1]); + act(() => { + jest.runAllTimers(); + }); + + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(2); + + await user.hover(within(dialogs[0]).getAllByRole('menuitem')[0]); + act(() => { + jest.runAllTimers(); + }); + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + }); + + it('should contain focus even for virtual focus', async function () { + let {getByRole, getAllByRole} = (renderers.subdialogs!)(); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + act(() => { + jest.runAllTimers(); + }); + + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + let subDialogInput = within(dialogs[0]).getByRole('searchbox'); + expect(document.activeElement).toBe(subDialogInput); + + await user.tab(); + expect(document.activeElement).toBe(subDialogInput); + await user.tab({shift: true}); + expect(document.activeElement).toBe(subDialogInput); + }); + + it('should only close a single level when hitting Escape and focus should be moved back to the input', async function () { + let {getByRole, getAllByRole, queryAllByRole} = (renderers.subdialogs!)(); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + // Open subdialog + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + let subDialogInput = within(dialogs[0]).getByRole('searchbox'); + expect(document.activeElement).toBe(subDialogInput); + + // Open the nested submenu + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + act(() => jest.runAllTimers()); + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(2); + let subDialogInput2 = within(dialogs[1]).getByRole('searchbox'); + expect(document.activeElement).toBe(subDialogInput2); + + // Close subdialogs and check that previous focus locations are retained + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + expect(document.activeElement).toBe(subDialogInput); + let subDialogMenuItems = within(dialogs[0]).getAllByRole('menuitem'); + expect(subDialogMenuItems[1]).toHaveAttribute('aria-haspopup', 'dialog'); + expect(subDialogInput).toHaveAttribute('aria-activedescendant', subDialogMenuItems[1].id); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + expect(document.activeElement).toBe(input); + }); + + // TODO: not sure why this is causing other tests to fail... Something with calling fireEvent? + it.skip('should close the current subdialog when clicking the dismiss button', function () { + let {getByRole, getAllByRole, queryAllByRole} = (renderers.subdialogs!)(); + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + + act(() => input.focus()); + fireEvent.click(input, {pointerType: 'mouse', width: 1, height: 1, detail: 0}); + expect(document.activeElement).toBe(input); + let options = within(menu).getAllByRole('menuitem'); + expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + + act(() => options[1].focus()); + fireEvent.click(options[1], {pointerType: 'mouse', width: 1, height: 1, detail: 0}); + act(() => jest.runAllTimers()); + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + + let popover = dialogs[0].closest('.react-aria-Popover'); + let dismissButtons = within(popover as HTMLElement).getAllByRole('button', {hidden: true}); + expect(dismissButtons.length).toBe(2); + act(() => dismissButtons[1].focus()); + fireEvent.click(dismissButtons[1], {pointerType: 'mouse', width: 1, height: 1, detail: 0}); + act(() => jest.runAllTimers()); + + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + expect(document.activeElement).toBe(options[1]); + act(() => jest.runAllTimers()); + }); + }); + } + + if (renderers.subdialogAndMenu) { + describe('with subdialogs and menus mixed', function () { + it('should allow opening a subdialog from menu and vice versa', async function () { + // Tests a mix of virtual focus and non virtual focus + let {getByRole, getAllByRole, queryAllByRole} = (renderers.subdialogAndMenu!)(); + let input = getByRole('searchbox'); + let menus = getAllByRole('menu'); + + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + let options = within(menus[0]).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); + + // Open subdialog + await user.keyboard('{ArrowRight}'); + act(() => {jest.runAllTimers();}); + + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + let subDialogInput = within(dialogs[0]).getByRole('searchbox'); + expect(document.activeElement).toBe(subDialogInput); + let subDialogMenuItems = within(dialogs[0]).getAllByRole('menuitem'); + expect(subDialogMenuItems[1]).toHaveAttribute('aria-haspopup', 'menu'); + + // Open submenu + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + act(() => {jest.runAllTimers();}); + menus = getAllByRole('menu'); + // 3 menus, 2 from autocomplete dialogs and one from submenu + expect(menus).toHaveLength(3); + expect(menus[2]).toContainElement(document.activeElement as HTMLElement); + let submenuItems = within(menus[2]).getAllByRole('menuitem'); + expect(submenuItems[1]).toHaveAttribute('aria-haspopup', 'dialog'); + + // Open last subdialog + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + act(() => {jest.runAllTimers();}); + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(2); + let subDialogInput2 = within(dialogs[1]).getByRole('searchbox'); + expect(document.activeElement).toBe(subDialogInput2); + + // Check focus is restored to the expected places when closing dialogs/menus + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + expect(document.activeElement).toBe(submenuItems[1]); + + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + menus = getAllByRole('menu'); + expect(menus).toHaveLength(2); + expect(document.activeElement).toBe(subDialogInput); + expect(subDialogInput).toHaveAttribute('aria-activedescendant', subDialogMenuItems[1].id); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + }); + }); + } }); }; diff --git a/packages/react-aria-components/test/AriaMenu.test-util.tsx b/packages/react-aria-components/test/AriaMenu.test-util.tsx index 87a1fc1a67b..17255bf59f0 100644 --- a/packages/react-aria-components/test/AriaMenu.test-util.tsx +++ b/packages/react-aria-components/test/AriaMenu.test-util.tsx @@ -712,10 +712,10 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) => expect(menu).not.toBeInTheDocument(); }); - it('should close nested submenus with Escape', async () => { + it('should close current submenu with Escape', async () => { let tree = (renderers.submenus!)(); - let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container}); + let menuTester = testUtilUser.createTester('Menu', {user, root: tree.container, interactionType: 'keyboard'}); await menuTester.open(); let menu = menuTester.menu; @@ -732,10 +732,10 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) => await user.keyboard('[Escape]'); act(() => {jest.runAllTimers();}); act(() => {jest.runAllTimers();}); - expect(menu).not.toBeInTheDocument(); - expect(submenu).not.toBeInTheDocument(); + expect(menu).toBeInTheDocument(); + expect(submenu).toBeInTheDocument(); expect(nestedSubmenu).not.toBeInTheDocument(); - expect(document.activeElement).toBe(menuTester.trigger); + expect(document.activeElement).toBe(nestedSubmenuTrigger); }); }); } diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 3dd5858706a..2214bab4922 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; -import {Button, Dialog, DialogTrigger, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, SearchField, Separator, Text, TextField, UNSTABLE_Autocomplete} from '..'; -import {pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {Button, Dialog, DialogTrigger, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, SearchField, Select, SelectValue, Separator, UNSTABLE_SubDialogTrigger as SubDialogTrigger, SubmenuTrigger, Text, TextField, UNSTABLE_Autocomplete} from '..'; import React, {ReactNode} from 'react'; import {useAsyncList} from 'react-stately'; import {useFilter} from '@react-aria/i18n'; @@ -67,6 +67,110 @@ let MenuWithSections = (props) => ( ); +// TODO: add tests for nested submenus and subdialogs +let SubMenus = (props) => ( + + Foo + + Bar + + + Lvl 1 Bar 1 + + Lvl 1 Bar 2 + + + Lvl 2 Bar 1 + Lvl 2 Bar 2 + Lvl 2 Bar 3 + + + + Lvl 1 Bar 3 + + + + Baz + +); + +let SubDialogs = (props) => ( + + Foo + + Bar + + + + + Lvl 1 Bar 1 + + Lvl 1 Bar 2 + + + + + Lvl 2 Bar 1 + Lvl 2 Bar 2 + Lvl 2 Bar 3 + + + + + + Lvl 1 Bar 3 + + + + + + Baz + +); + +let SubDialogAndMenu = (props) => ( + + Foo + + Bar + + + + + Lvl 1 Bar 1 + + Lvl 1 Bar 2 + + + Lvl 2 Bar 1 + + Lvl 2 Bar 2 + + + + + Lvl 3 Bar 1 + Lvl 3 Bar 2 + Lvl 3 Bar 3 + + + + + + Lvl 2 Bar 3 + + + + Lvl 1 Bar 3 + + + + + + Baz + +); + let StaticListbox = (props) => ( Foo @@ -180,6 +284,11 @@ describe('Autocomplete', () => { let user; beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => jest.runAllTimers()); }); // Skipping since arrow keys will still leak out from useSelectableCollection, re-enable when that gets fixed @@ -232,17 +341,144 @@ describe('Autocomplete', () => { let input = getByRole('searchbox'); await user.tab(); expect(document.activeElement).toBe(input); + // Focus ring should be on input when no aria-activeelement + expect(input).toHaveAttribute('data-focus-visible'); + + // Focus ring should be on option when it is the active descendant and keyboard modality await user.keyboard('{ArrowDown}'); let menu = getByRole('menu'); let options = within(menu).getAllByRole('menuitem'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); expect(options[0]).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + // Focus ring should not be on either input or option when hovering (aka mouse modality) await user.click(input); await user.hover(options[1]); options = within(menu).getAllByRole('menuitem'); expect(options[1]).toHaveAttribute('data-focused'); expect(options[1]).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + + // Reset focus visible on input so that isTextInput in useFocusRing doesn't prevent the focus ring + // from appearing on the input + await user.tab(); + await user.tab({shift: true}); + + // Focus ring should be on option after typing and option is autofocused + await user.keyboard('Bar'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + + // Focus ring should be on input after clearing focus via ArrowLeft + await user.keyboard('{ArrowLeft}'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + input = getByRole('searchbox'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(input).toHaveAttribute('data-focus-visible'); + + // Focus ring should be on input after clearing focus via Backspace + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(input).toHaveAttribute('data-focus-visible'); + }); + + it('should not display focus in the virtually focused menu if focus isn\'t in the autocomplete input', async function () { + let {getByRole} = render( + <> + + + + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focus-visible'); + await user.keyboard('{ArrowDown}'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + + await user.tab(); + expect(document.activeElement).not.toBe(input); + expect(options[0]).not.toHaveAttribute('data-focused'); + expect(options[0]).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + + await user.tab({shift: true}); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(input); + expect(options[0]).toHaveAttribute('data-focused'); + expect(options[0]).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + }); + + it('should work inside a Select', async function () { + let {getByRole} = render( + + ); + + let button = getByRole('button'); + await user.tab(); + expect(document.activeElement).toBe(button); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + let searchfield = getByRole('searchbox'); + expect(document.activeElement).toBe(searchfield); + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(3); + expect(searchfield).toHaveAttribute('aria-activedescendant', options[0].id); + expect(options[0]).toHaveAttribute('data-focus-visible'); + + await user.keyboard('{ArrowDown}'); + expect(searchfield).toHaveAttribute('aria-activedescendant', options[1].id); + + await user.keyboard('b'); + options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(2); + expect(searchfield).toHaveAttribute('aria-activedescendant', options[0].id); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + expect(listbox).not.toBeInTheDocument(); + expect(document.activeElement).toBe(button); + expect(button).toHaveTextContent('Bar'); }); it('should be able to tab inside a focus scope that contains', async () => { @@ -418,6 +654,21 @@ AriaAutocompleteTests({ + ), + submenus: () => render( + + + + ), + subdialogs: () => render( + + + + ), + subdialogAndMenu: () => render( + + + ) }, actionListener: onAction, diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index b8af5cef8b6..11378a65bf4 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -12,9 +12,10 @@ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaMenuTests} from './AriaMenu.test-util'; -import {Button, Collection, Header, Keyboard, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text} from '..'; +import {Button, Collection, Dialog, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField} from '..'; import React, {useState} from 'react'; import {Selection, SelectionMode} from '@react-types/shared'; +import {SubDialogTrigger} from '../src/Menu'; import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -843,12 +844,12 @@ describe('Menu', () => { act(() => {jest.runAllTimers();}); expect(submenu).not.toBeInTheDocument(); - expect(menu).not.toBeInTheDocument(); - expect(document.activeElement).toBe(button); + expect(menu).toBeInTheDocument(); + expect(document.activeElement).toBe(triggerItem); }); - it('should restore focus to menu trigger if nested submenu is closed with Escape key', async () => { + it('should restore focus to nested submenu trigger if nested submenu is closed with Escape key', async () => { document.elementFromPoint = jest.fn().mockImplementation(query => query); - let {getByRole, getAllByRole} = render( + let {getByRole} = render( @@ -880,20 +881,20 @@ describe('Menu', () => { ); - let button = getByRole('button'); - expect(button).not.toHaveAttribute('data-pressed'); + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button'), interactionType: 'keyboard'}); - await user.click(button); - expect(button).toHaveAttribute('data-pressed'); + expect(menuTester.trigger).not.toHaveAttribute('data-pressed'); + await menuTester.open(); + expect(menuTester.trigger).toHaveAttribute('data-pressed'); - let menu = getAllByRole('menu')[0]; - expect(getAllByRole('menuitem')).toHaveLength(5); + expect(menuTester.options()).toHaveLength(5); + expect(menuTester.menu).toBeInTheDocument(); - let popover = menu.closest('.react-aria-Popover'); + let popover = menuTester.menu?.closest('.react-aria-Popover'); expect(popover).toBeInTheDocument(); expect(popover).toHaveAttribute('data-trigger', 'MenuTrigger'); - let triggerItem = getAllByRole('menuitem')[3]; + let triggerItem = menuTester.submenuTriggers[0]; expect(triggerItem).toHaveTextContent('Share…'); expect(triggerItem).toHaveAttribute('aria-haspopup', 'menu'); expect(triggerItem).toHaveAttribute('aria-expanded', 'false'); @@ -902,36 +903,27 @@ describe('Menu', () => { // Open the submenu await user.pointer({target: triggerItem}); + let submenuTester = await menuTester.openSubmenu({submenuTrigger: triggerItem}); act(() => {jest.runAllTimers();}); expect(triggerItem).toHaveAttribute('data-hovered', 'true'); expect(triggerItem).toHaveAttribute('aria-expanded', 'true'); expect(triggerItem).toHaveAttribute('data-open', 'true'); - let submenu = getAllByRole('menu')[1]; - expect(submenu).toBeInTheDocument(); - - let submenuItems = within(submenu).getAllByRole('menuitem'); - expect(submenuItems).toHaveLength(3); + expect(submenuTester?.menu).toBeInTheDocument(); + expect(submenuTester?.options()).toHaveLength(3); // Open the nested submenu - await user.pointer({target: submenuItems[0]}); + let nestedSubmenu = await submenuTester?.openSubmenu({submenuTrigger: 'Email…'}); act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(submenuItems[0]); - - let nestedSubmenu = getAllByRole('menu')[1]; - expect(nestedSubmenu).toBeInTheDocument(); - - let nestedSubmenuItems = within(nestedSubmenu).getAllByRole('menuitem'); - await user.pointer({target: nestedSubmenuItems[0]}); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(nestedSubmenuItems[0]); + expect(nestedSubmenu?.menu).toBeInTheDocument(); + expect(document.activeElement).toBe(nestedSubmenu?.options()[0]); await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); - expect(nestedSubmenu).not.toBeInTheDocument(); - expect(submenu).not.toBeInTheDocument(); - expect(menu).not.toBeInTheDocument(); - expect(document.activeElement).toBe(button); + expect(nestedSubmenu?.menu).not.toBeInTheDocument(); + expect(submenuTester?.menu).toBeInTheDocument(); + expect(menuTester.menu).toBeInTheDocument(); + expect(document.activeElement).toBe(nestedSubmenu?.trigger); }); it('should not close the menu when clicking on a element within the submenu tree', async () => { let onAction = jest.fn(); @@ -1104,6 +1096,266 @@ describe('Menu', () => { }); }); + describe('Subdialog', function () { + it('should contain focus for subdialogs', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole} = render( + + + + + Open + Rename… + Duplicate + + Share… + + + {({close}) => ( +
+ Sign up + + + + + + + + + +
+ )} +
+
+
+ Delete… +
+
+
+ ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')}); + expect(menuTester.trigger).not.toHaveAttribute('data-pressed'); + + await menuTester.open(); + expect(menuTester.trigger).toHaveAttribute('data-pressed'); + expect(menuTester.options()).toHaveLength(5); + + let popover = menuTester.menu?.closest('.react-aria-Popover'); + expect(popover).toBeInTheDocument(); + expect(popover).toHaveAttribute('data-trigger', 'MenuTrigger'); + + let triggerItem = menuTester.submenuTriggers[0]; + expect(triggerItem).toHaveTextContent('Share…'); + expect(triggerItem).toHaveAttribute('aria-haspopup', 'dialog'); + expect(triggerItem).toHaveAttribute('aria-expanded', 'false'); + // TODO: should this have a different data attribute aka has-subdialog? + expect(triggerItem).toHaveAttribute('data-has-submenu', 'true'); + expect(triggerItem).not.toHaveAttribute('data-open'); + + // Open the subdialog + await menuTester.openSubmenu({submenuTrigger: triggerItem}); + act(() => {jest.runAllTimers();}); + expect(triggerItem).toHaveAttribute('data-hovered', 'true'); + expect(triggerItem).toHaveAttribute('aria-expanded', 'true'); + expect(triggerItem).toHaveAttribute('data-open', 'true'); + let subdialog = getAllByRole('dialog')[0]; + expect(subdialog).toBeInTheDocument(); + + let subdialogPopover = subdialog.closest('.react-aria-Popover') as HTMLElement; + expect(subdialogPopover).toBeInTheDocument(); + expect(subdialogPopover).toHaveAttribute('data-trigger', 'SubDialogTrigger'); + + let inputs = within(subdialogPopover).getAllByRole('textbox'); + let buttons = within(subdialogPopover).getAllByRole('button'); + await user.click(inputs[0]); + expect(document.activeElement).toBe(inputs[0]); + await user.tab(); + expect(document.activeElement).toBe(inputs[1]); + await user.tab(); + expect(document.activeElement).toBe(buttons[0]); + await user.tab(); + expect(document.activeElement).toBe(inputs[0]); + }); + + it('should support nested subdialogs', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, queryAllByRole} = render( + + + + + Open + Rename… + Duplicate + + Share… + + + {({close}) => ( + <> + + + Nested Subdialog + + + {({close}) => ( +
+ Contact + + + + + + + + + +
+ )} +
+
+
+ B + C +
+ + + )} +
+
+
+ Delete… +
+
+
+ ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')}); + await menuTester.open(); + + let triggerItem = menuTester.submenuTriggers[0]; + expect(triggerItem).toHaveTextContent('Share…'); + expect(triggerItem).toHaveAttribute('aria-haspopup', 'dialog'); + + // Open the subdialog + let subDialogTester = await menuTester.openSubmenu({submenuTrigger: triggerItem}); + act(() => {jest.runAllTimers();}); + expect(subDialogTester?.menu).toBeInTheDocument(); + + let subDialogTriggerItem = subDialogTester?.submenuTriggers[0]; + expect(subDialogTriggerItem).toHaveTextContent('Nested Subdialog'); + expect(subDialogTriggerItem).toHaveAttribute('aria-haspopup', 'dialog'); + + // Open the nested subdialog + await subDialogTester?.openSubmenu({submenuTrigger: subDialogTriggerItem!}); + act(() => {jest.runAllTimers();}); + let subdialogs = getAllByRole('dialog'); + expect(subdialogs).toHaveLength(2); + + await user.keyboard('{Escape}'); + act(() => {jest.runAllTimers();}); + subdialogs = getAllByRole('dialog'); + expect(subdialogs).toHaveLength(1); + expect(document.activeElement).toBe(subDialogTriggerItem); + + await user.keyboard('{Escape}'); + act(() => {jest.runAllTimers();}); + subdialogs = queryAllByRole('dialog'); + expect(subdialogs).toHaveLength(0); + expect(document.activeElement).toBe(triggerItem); + }); + + it('should close all subdialogs if interacting outside the root menu', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, queryAllByRole} = render( + + + + + Open + Rename… + Duplicate + + Share… + + + {({close}) => ( + <> + + + Nested Subdialog + + + {({close}) => ( +
+ Contact + + + + + + + + + +
+ )} +
+
+
+ B + C +
+ + + )} +
+
+
+ Delete… +
+
+
+ ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')}); + await menuTester.open(); + + // Open the subdialog + let triggerItem = menuTester.submenuTriggers[0]; + + let subDialogTester = await menuTester.openSubmenu({submenuTrigger: triggerItem}); + act(() => {jest.runAllTimers();}); + expect(subDialogTester?.menu).toBeInTheDocument(); + + // Open the nested subdialog + let subDialogTriggerItem = subDialogTester?.submenuTriggers[0]; + await subDialogTester?.openSubmenu({submenuTrigger: subDialogTriggerItem!}); + act(() => {jest.runAllTimers();}); + let subdialogs = getAllByRole('dialog'); + expect(subdialogs).toHaveLength(2); + + await user.click(document.body); + act(() => {jest.runAllTimers();}); + subdialogs = queryAllByRole('dialog'); + expect(subdialogs).toHaveLength(0); + expect(menuTester.menu).not.toBeInTheDocument(); + }); + + // TODO: add test where clicking in a parent subdialog should close the nested subdialog when we fix that use case + }); + describe('portalContainer', () => { function InfoMenu(props) { return ( diff --git a/packages/react-stately/src/index.ts b/packages/react-stately/src/index.ts index 133bedf2913..96a98f88b58 100644 --- a/packages/react-stately/src/index.ts +++ b/packages/react-stately/src/index.ts @@ -43,7 +43,7 @@ export {useDisclosureState, useDisclosureGroupState} from '@react-stately/disclo export {useDraggableCollectionState, useDroppableCollectionState} from '@react-stately/dnd'; export {Item, Section, useCollection} from '@react-stately/collections'; export {useAsyncList, useListData, useTreeData} from '@react-stately/data'; -export {useListState, useSingleSelectListState} from '@react-stately/list'; +export {useListState, useSingleSelectListState, useFilteredListState} from '@react-stately/list'; export {useMenuTriggerState, useSubmenuTriggerState} from '@react-stately/menu'; export {useNumberFieldState} from '@react-stately/numberfield'; export {useOverlayTriggerState} from '@react-stately/overlays'; diff --git a/yarn.lock b/yarn.lock index ca28f81df56..dd6553e88a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5912,6 +5912,7 @@ __metadata: resolution: "@react-aria/autocomplete@workspace:packages/@react-aria/autocomplete" dependencies: "@react-aria/combobox": "npm:^3.11.1" + "@react-aria/focus": "npm:^3.19.1" "@react-aria/i18n": "npm:^3.12.5" "@react-aria/interactions": "npm:^3.23.0" "@react-aria/listbox": "npm:^3.14.0" @@ -7912,7 +7913,7 @@ __metadata: "@react-types/textfield": "npm:^3.11.0" "@testing-library/dom": "npm:^10.1.0" "@testing-library/react": "npm:^15.0.7" - "@testing-library/user-event": "npm:^14.0.0" + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" csstype: "npm:^3.0.2" jest: "npm:^29.5.0" react-aria: "npm:^3.37.0" @@ -8176,7 +8177,7 @@ __metadata: "@testing-library/dom": "npm:^10.1.0" "@testing-library/jest-dom": "npm:^5.16.4" "@testing-library/react": "npm:^15.0.7" - "@testing-library/user-event": "npm:^14.4.3" + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" jest: "npm:^29.5.0" resolve: "npm:^1.17.0" peerDependencies: @@ -10889,7 +10890,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.0.0, @testing-library/user-event@npm:^14.4.0, @testing-library/user-event@npm:^14.4.3, @testing-library/user-event@npm:^14.6.1": +"@testing-library/user-event@npm:14.6.1": version: 14.6.1 resolution: "@testing-library/user-event@npm:14.6.1" peerDependencies: @@ -10898,6 +10899,15 @@ __metadata: languageName: node linkType: hard +"@testing-library/user-event@patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch": + version: 14.6.1 + resolution: "@testing-library/user-event@patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch::version=14.6.1&hash=13cf21" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: 10c0/ede32fec9345bb5e5c19a5abcb647d8c4704239f3f5417afe2914c1397067dae7ce547e46adfd4027c913f5735c0651ec530c73bdc5c7ea955efa860cc6a9dd9 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -29338,7 +29348,7 @@ __metadata: "@testing-library/dom": "npm:^10.1.0" "@testing-library/jest-dom": "npm:^5.16.5" "@testing-library/react": "npm:^15.0.7" - "@testing-library/user-event": "npm:^14.6.1" + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" "@types/react": "npm:types-react@19.0.0-rc.0" "@types/react-dom": "npm:types-react-dom@19.0.0-rc.0" "@types/storybook__react": "npm:^4.0.2"