diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 70b65a09a37..9586c5ed1be 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -44,9 +44,12 @@ import {DataTransfer, DragEvent} from 'react-aria/test/dnd/mocks'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {DropIndicator, useDragAndDrop} from '../src/useDragAndDrop'; import {Label} from '../src/Label'; +import {ListBox, ListBoxItem} from '../src/ListBox'; import {Modal} from '../src/Modal'; +import {Popover} from '../src/Popover'; import React, {useMemo, useState} from 'react'; import {resizingTests} from 'react-aria/test/table/tableResizingTests.tsx'; +import {Select, SelectValue} from '../src/Select'; import {setInteractionModality} from 'react-aria/private/interactions/useFocusVisible'; import * as stories from '../stories/Table.stories'; import {TableLayout} from '../src/TableLayout'; @@ -1209,6 +1212,76 @@ describe('Table', () => { expect(document.activeElement).toBe(rowElements[3]); }); + it.skip('should select inside a cell using typeahead before the table takes over typeahead', async () => { + let rows = [ + {id: 1, name: '1. Games', date: '6/7/2020', type: 'File folder', textValue: 'Games'}, + { + id: 2, + name: '2. Program Files', + date: '4/7/2021', + type: 'File folder', + textValue: 'Program Files' + }, + {id: 3, name: '3. bootmgr', date: '11/20/2010', type: 'System file', textValue: 'bootmgr'}, + {id: 4, name: '4. log.txt', date: '1/18/2016', type: 'Text Document', textValue: 'log.txt'} + ]; + let {getAllByRole} = render( + + + {column => ( + + {column.name} + + )} + + + {item => ( + + {column => { + if (column.id !== 'name') { + return ( + +
{item[column.id]}
+
+ ); + } + return ( + +
+ {item[column.id]} + +
+
+ ); + }} +
+ )} +
+
+ ); + let rowElements = getAllByRole('row'); + + await user.tab(); + expect(document.activeElement).toBe(rowElements[1]); + await user.keyboard('{ArrowRight}'); + let select = within(rowElements[1]).getByRole('button'); + expect(document.activeElement).toBe(select); + await user.keyboard('boo'); + expect(document.activeElement).toBe(select); + expect(select).toHaveTextContent('boo'); + }); + it('should support updating columns', () => { let tree = render( ( let {keyboardProps} = useKeyboard({ shortcuts: { ArrowRight: () => { + let next; if (flipDirection) { - focusManager.focusPrevious({wrap: true}); + next = focusManager.focusPrevious({wrap: true, action: 'ArrowRight'}); } else { - focusManager.focusNext({wrap: true}); + next = focusManager.focusNext({wrap: true, action: 'ArrowRight'}); } + return next !== null; }, ArrowDown: () => { - focusManager.focusNext({wrap: true}); + let next = focusManager.focusNext({wrap: true, action: 'ArrowDown'}); + return next !== null; }, ArrowLeft: () => { + let next; if (flipDirection) { - focusManager.focusNext({wrap: true}); + next = focusManager.focusNext({wrap: true, action: 'ArrowLeft'}); } else { - focusManager.focusPrevious({wrap: true}); + next = focusManager.focusPrevious({wrap: true, action: 'ArrowLeft'}); } + return next !== null; }, ArrowUp: () => { - focusManager.focusPrevious({wrap: true}); + let next = focusManager.focusPrevious({wrap: true, action: 'ArrowUp'}); + return next !== null; } } }); diff --git a/packages/react-aria/src/dnd/DragManager.ts b/packages/react-aria/src/dnd/DragManager.ts index 637cdf0daf6..efd9cd8df15 100644 --- a/packages/react-aria/src/dnd/DragManager.ts +++ b/packages/react-aria/src/dnd/DragManager.ts @@ -226,6 +226,7 @@ class DragSession { } onKeyDown(e: KeyboardEvent): void { + // TODO: should these be stopped? this.cancelEvent(e); if (e.key === 'Escape') { diff --git a/packages/react-aria/src/focus/FocusScope.tsx b/packages/react-aria/src/focus/FocusScope.tsx index 6f423f12b7f..fb080ba3061 100644 --- a/packages/react-aria/src/focus/FocusScope.tsx +++ b/packages/react-aria/src/focus/FocusScope.tsx @@ -43,6 +43,8 @@ export interface FocusScopeProps { } export interface FocusManagerOptions { + /** The action that triggered the focus movement. */ + action?: string; /** The element to start searching from. The currently focused element by default. */ from?: Element; /** Whether to only include tabbable elements, or all focusable elements. */ @@ -923,6 +925,18 @@ export function createFocusManager( if (!nextNode && wrap) { walker.currentNode = root; nextNode = walker.nextNode() as FocusableElement; + if (nextNode) { + let event = new CustomEvent('focus-manager-focus-wrap', { + bubbles: true, + cancelable: true, + detail: {action: opts.action} + }); + let target = from || getActiveElement(getOwnerDocument(root))!; + target.dispatchEvent(event); + if (event.defaultPrevented) { + return null; + } + } } if (nextNode) { focusElement(nextNode, true); @@ -960,6 +974,18 @@ export function createFocusManager( return null; } previousNode = lastNode; + if (previousNode) { + let event = new CustomEvent('focus-manager-focus-wrap', { + bubbles: true, + cancelable: true, + detail: {action: opts.action} + }); + let target = from || getActiveElement(getOwnerDocument(root))!; + target.dispatchEvent(event); + if (event.defaultPrevented) { + return null; + } + } } if (previousNode) { focusElement(previousNode, true); diff --git a/packages/react-aria/src/toolbar/useToolbar.ts b/packages/react-aria/src/toolbar/useToolbar.ts index fa76006fae9..a28cea2531f 100644 --- a/packages/react-aria/src/toolbar/useToolbar.ts +++ b/packages/react-aria/src/toolbar/useToolbar.ts @@ -13,8 +13,9 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '../focus/FocusScope'; import {filterDOMProps} from '../utils/filterDOMProps'; -import {FocusEventHandler, HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; +import {FocusEventHandler, HTMLAttributes, useEffect, useRef, useState} from 'react'; import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; @@ -58,54 +59,80 @@ export function useToolbar( setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]'))); }); const {direction} = useLocale(); - const shouldReverse = direction === 'rtl' && orientation === 'horizontal'; // oxlint-disable-next-line react/react-compiler let focusManager = createFocusManager(ref); - const onKeyDown: KeyboardEventHandler = e => { - // don't handle portalled events - if (!nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement)) { - return; - } - if ( - (orientation === 'horizontal' && e.key === 'ArrowRight') || - (orientation === 'vertical' && e.key === 'ArrowDown') - ) { - if (shouldReverse) { - focusManager.focusPrevious(); - } else { - focusManager.focusNext(); + useEffect(() => { + const onFocusManagerFocusWrap = (e: CustomEvent<{action: string}>) => { + if ( + (orientation === 'horizontal' && + (e.detail.action === 'ArrowRight' || e.detail.action === 'ArrowLeft')) || + (orientation === 'vertical' && + (e.detail.action === 'ArrowDown' || e.detail.action === 'ArrowUp')) + ) { + e.preventDefault(); } - } else if ( - (orientation === 'horizontal' && e.key === 'ArrowLeft') || - (orientation === 'vertical' && e.key === 'ArrowUp') - ) { - if (shouldReverse) { - focusManager.focusNext(); - } else { - focusManager.focusPrevious(); - } - } else if (e.key === 'Tab') { - // When the tab key is pressed, we want to move focus - // out of the entire toolbar. To do this, move focus - // to the first or last focusable child, and let the - // browser handle the Tab key as usual from there. - lastFocused.current = getActiveElement() as HTMLElement; - if (e.shiftKey) { - focusManager.focusFirst(); - } else { + }; + let toolbar = ref.current; + toolbar?.addEventListener('focus-manager-focus-wrap', onFocusManagerFocusWrap as EventListener); + return () => + toolbar?.removeEventListener( + 'focus-manager-focus-wrap', + onFocusManagerFocusWrap as EventListener + ); + }, [ref, orientation]); + + let flipDirection = direction === 'rtl' && orientation === 'horizontal'; + let {keyboardProps} = useKeyboard({ + shortcuts: { + ArrowRight: () => { + let next; + if (orientation === 'horizontal') { + if (flipDirection) { + next = focusManager.focusPrevious({wrap: false, action: 'ArrowRight'}); + } else { + next = focusManager.focusNext({wrap: false, action: 'ArrowRight'}); + } + } + return next !== null; + }, + ArrowLeft: () => { + let next; + if (orientation === 'horizontal') { + if (flipDirection) { + next = focusManager.focusNext({wrap: false, action: 'ArrowLeft'}); + } else { + next = focusManager.focusPrevious({wrap: false, action: 'ArrowLeft'}); + } + } + return next !== null; + }, + ArrowDown: () => { + let next; + if (orientation === 'vertical') { + next = focusManager.focusNext({wrap: false, action: 'ArrowDown'}); + } + return next !== null; + }, + ArrowUp: () => { + let next; + if (orientation === 'vertical') { + next = focusManager.focusPrevious({wrap: false, action: 'ArrowUp'}); + } + return next !== null; + }, + Tab: () => { + lastFocused.current = getActiveElement() as HTMLElement; focusManager.focusLast(); + return false; + }, + 'Shift+Tab': () => { + lastFocused.current = getActiveElement() as HTMLElement; + focusManager.focusFirst(); + return false; } - return; - } else { - // if we didn't handle anything, return early so we don't preventDefault - return; } - - // Prevent arrow keys from being handled by nested action groups. - e.stopPropagation(); - e.preventDefault(); - }; + }); // Record the last focused child when focus moves out of the toolbar. const lastFocused = useRef(null); @@ -136,7 +163,8 @@ export function useToolbar( 'aria-orientation': orientation, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabel == null ? ariaLabelledBy : undefined, - onKeyDownCapture: !isInToolbar ? onKeyDown : undefined, + onKeyDown: !isInToolbar ? keyboardProps.onKeyDown : undefined, + onKeyUp: !isInToolbar ? keyboardProps.onKeyUp : undefined, onFocusCapture: !isInToolbar ? onFocus : undefined, onBlurCapture: !isInToolbar ? onBlur : undefined }