diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 1f83529ca71..ed9a8413b05 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'; @@ -1171,6 +1174,76 @@ describe('Table', () => { expect(document.activeElement).toBe(rowElements[3]); }); + it('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 bcb558fb804..2f508758d16 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. */ @@ -921,6 +923,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); @@ -958,6 +972,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/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index a7f366eab2d..a9e2d7c0e5d 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -130,6 +130,9 @@ export function useGridCell>( isDisabled: state.collection.size === 0 }); + // TODO: decide move away from capturing. We may not want to because it + // prevents events from reaching content of cells, which can't have interactive children anyways. + // So by preventing them, users are alerted to the fact that this isn't an allowed pattern. let onKeyDownCapture = (e: ReactKeyboardEvent) => { let activeElement = getActiveElement(); if ( diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index d70ea835356..44e31a894ba 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -21,22 +21,18 @@ import { Node as RSNode } from '@react-types/shared'; import {focusSafely} from '../interactions/focusSafely'; -import { - getActiveElement, - getEventTarget, - isFocusWithin, - nodeContains -} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, getEventTarget, isFocusWithin} from '../utils/shadowdom/DOMFunctions'; import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getRowId, listMap} from './utils'; import {getScrollParent} from '../utils/getScrollParent'; -import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; +import {HTMLAttributes, useEffect, useRef} from 'react'; import {isFocusVisible} from '../interactions/useFocusVisible'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {SelectableItemStates, useSelectableItem} from '../selection/useSelectableItem'; import type {TreeState} from 'react-stately/useTreeState'; +import {useKeyboard} from '../interactions/useKeyboard'; import {useLocale} from '../i18n/I18nProvider'; import {useSlotId} from '../utils/useId'; import {useSyntheticLinkProps} from '../utils/openLink'; @@ -64,17 +60,6 @@ export interface GridListItemAria extends SelectableItemStates { descriptionProps: DOMAttributes; } -const EXPANSION_KEYS = { - expand: { - ltr: 'ArrowRight', - rtl: 'ArrowLeft' - }, - collapse: { - ltr: 'ArrowLeft', - rtl: 'ArrowRight' - } -}; - /** * Provides the behavior and accessibility implementation for a row in a grid list. * @@ -168,70 +153,69 @@ export function useGridListItem( linkBehavior }); - let onKeyDownCapture = (e: ReactKeyboardEvent) => { - let activeElement = getActiveElement(); - if ( - !nodeContains(e.currentTarget, getEventTarget(e) as Element) || - !ref.current || - !activeElement - ) { - return; + useEffect(() => { + let element = ref.current; + let handleFocusManagerFocusWrap = (e: Event) => { + e.preventDefault(); + }; + if (element) { + element.addEventListener('focus-manager-focus-wrap', handleFocusManagerFocusWrap); } + return () => { + if (element) { + element.removeEventListener('focus-manager-focus-wrap', handleFocusManagerFocusWrap); + } + }; + }, [ref]); - let walker = getFocusableTreeWalker(ref.current); - walker.currentNode = activeElement; - - if ('expandedKeys' in state && activeElement === ref.current) { - if ( - e.key === EXPANSION_KEYS['expand'][direction] && - state.selectionManager.focusedKey === node.key && - hasChildRows && - !state.expandedKeys.has(node.key) - ) { - state.toggleKey(node.key); - e.stopPropagation(); - return; - } else if ( - e.key === EXPANSION_KEYS['collapse'][direction] && - state.selectionManager.focusedKey === node.key - ) { - // If item is collapsible, collapse it; else move to parent - if (hasChildRows && state.expandedKeys.has(node.key)) { - state.toggleKey(node.key); - e.stopPropagation(); - return; - } else if ( - !state.expandedKeys.has(node.key) && - node.parentKey && - state.collection.getItem(node.parentKey)?.type === 'item' - ) { - // Item is a leaf or already collapsed, move focus to parent - state.selectionManager.setFocusedKey(node.parentKey); - e.stopPropagation(); - return; + let {keyboardProps} = useKeyboard({ + shortcuts: { + ArrowRight: () => { + let activeElement = getActiveElement(); + if (!activeElement || !ref.current) { + return false; + } + if ('expandedKeys' in state && activeElement === ref.current) { + if ( + direction === 'ltr' && + state.selectionManager.focusedKey === node.key && + hasChildRows && + !state.expandedKeys.has(node.key) + ) { + state.toggleKey(node.key); + return true; + } else if (direction === 'rtl' && state.selectionManager.focusedKey === node.key) { + // If item is collapsible, collapse it; else move to parent + if (hasChildRows && state.expandedKeys.has(node.key)) { + state.toggleKey(node.key); + return true; + } else if ( + !state.expandedKeys.has(node.key) && + node.parentKey && + state.collection.getItem(node.parentKey)?.type === 'item' + ) { + // Item is a leaf or already collapsed, move focus to parent + state.selectionManager.setFocusedKey(node.parentKey); + return true; + } + } } - } - } - switch (e.key) { - case 'ArrowLeft': { + let walker = getFocusableTreeWalker(ref.current); + walker.currentNode = activeElement; + if (keyboardNavigationBehavior === 'arrow') { - // Find the next focusable element within the row. let focusable = direction === 'rtl' - ? (walker.nextNode() as FocusableElement) - : (walker.previousNode() as FocusableElement); + ? (walker.previousNode() as FocusableElement) + : (walker.nextNode() as FocusableElement); if (focusable) { - e.preventDefault(); - e.stopPropagation(); focusSafely(focusable); scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)}); + return true; } else { - // If there is no next focusable child, then return focus back to the row - e.preventDefault(); - e.stopPropagation(); - if (direction === 'rtl') { + if (direction === 'ltr') { focusSafely(ref.current); scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } else { @@ -243,26 +227,57 @@ export function useGridListItem( scrollIntoViewport(lastElement, {containingElement: getScrollParent(ref.current)}); } } + return true; } } - break; - } - case 'ArrowRight': { + return false; + }, + ArrowLeft: () => { + let activeElement = getActiveElement(); + if (!activeElement || !ref.current) { + return false; + } + if ('expandedKeys' in state && activeElement === ref.current) { + if ( + direction === 'rtl' && + state.selectionManager.focusedKey === node.key && + hasChildRows && + !state.expandedKeys.has(node.key) + ) { + state.toggleKey(node.key); + return true; + } else if (direction === 'ltr' && state.selectionManager.focusedKey === node.key) { + // If item is collapsible, collapse it; else move to parent + if (hasChildRows && state.expandedKeys.has(node.key)) { + state.toggleKey(node.key); + return true; + } else if ( + !state.expandedKeys.has(node.key) && + node.parentKey && + state.collection.getItem(node.parentKey)?.type === 'item' + ) { + // Item is a leaf or already collapsed, move focus to parent + state.selectionManager.setFocusedKey(node.parentKey); + return true; + } + } + } + + let walker = getFocusableTreeWalker(ref.current); + walker.currentNode = activeElement; + if (keyboardNavigationBehavior === 'arrow') { let focusable = - direction === 'rtl' + direction === 'ltr' ? (walker.previousNode() as FocusableElement) : (walker.nextNode() as FocusableElement); if (focusable) { - e.preventDefault(); - e.stopPropagation(); focusSafely(focusable); scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)}); + return true; } else { - e.preventDefault(); - e.stopPropagation(); - if (direction === 'ltr') { + if (direction === 'rtl') { focusSafely(ref.current); scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } else { @@ -274,25 +289,43 @@ export function useGridListItem( scrollIntoViewport(lastElement, {containingElement: getScrollParent(ref.current)}); } } + return true; } } - break; - } - case 'ArrowUp': - case 'ArrowDown': - // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate - // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still - // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { - e.stopPropagation(); - e.preventDefault(); - ref.current.parentElement?.dispatchEvent( - new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ); + return false; + }, + Tab: () => { + let activeElement = getActiveElement(); + if (keyboardNavigationBehavior === 'tab' && ref.current && activeElement) { + // If there is another focusable element within this item, stop propagation so the tab key + // is handled by the browser and not by useSelectableCollection (which would take us out of the list). + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + walker.currentNode = activeElement; + let next = walker.nextNode(); + + if (next) { + return {shouldPreventDefault: false, shouldContinuePropagation: false}; + } } - break; + return false; + }, + 'Shift+Tab': () => { + let activeElement = getActiveElement(); + if (keyboardNavigationBehavior === 'tab' && ref.current && activeElement) { + // If there is another focusable element within this item, stop propagation so the tab key + // is handled by the browser and not by useSelectableCollection (which would take us out of the list). + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + walker.currentNode = activeElement; + let next = walker.previousNode(); + + if (next) { + return {shouldPreventDefault: false, shouldContinuePropagation: false}; + } + } + return false; + } } - }; + }); let onFocus = e => { keyWhenFocused.current = node.key; @@ -310,33 +343,6 @@ export function useGridListItem( } }; - let onKeyDown = e => { - let activeElement = getActiveElement(); - if ( - !nodeContains(e.currentTarget, getEventTarget(e) as Element) || - !ref.current || - !activeElement - ) { - return; - } - - switch (e.key) { - case 'Tab': { - if (keyboardNavigationBehavior === 'tab') { - // If there is another focusable element within this item, stop propagation so the tab key - // is handled by the browser and not by useSelectableCollection (which would take us out of the list). - let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); - walker.currentNode = activeElement; - let next = e.shiftKey ? walker.previousNode() : walker.nextNode(); - - if (next) { - e.stopPropagation(); - } - } - } - } - }; - let syntheticLinkProps = useSyntheticLinkProps(node.props); let linkProps = itemStates.hasAction ? syntheticLinkProps : {}; // TODO: re-add when we get translations and fix this for iOS VO @@ -351,8 +357,7 @@ export function useGridListItem( let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', - onKeyDownCapture, - onKeyDown, + ...keyboardProps, onFocus, // 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '), 'aria-label': node['aria-label'] || node.textValue || undefined, diff --git a/packages/react-aria/src/select/useSelect.ts b/packages/react-aria/src/select/useSelect.ts index 7e54b43ae4e..7d561220454 100644 --- a/packages/react-aria/src/select/useSelect.ts +++ b/packages/react-aria/src/select/useSelect.ts @@ -184,8 +184,6 @@ export function useSelect( errorMessage: props.errorMessage || validationErrors }); - typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture; - delete typeSelectProps.onKeyDownCapture; if (state.selectionManager.selectionMode === 'multiple') { typeSelectProps = {}; } diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts index eae1215dad4..4d0c2d1711e 100644 --- a/packages/react-aria/src/selection/useTypeSelect.ts +++ b/packages/react-aria/src/selection/useTypeSelect.ts @@ -12,7 +12,7 @@ import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; -import {KeyboardEvent, useRef} from 'react'; +import {KeyboardEvent, useEffect, useRef} from 'react'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; /** @@ -50,41 +50,72 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let state = useRef<{search: string; timeout: ReturnType | undefined}>({ search: '', timeout: undefined - }).current; + }); + + let onKeyDownCapture = (e: KeyboardEvent) => { + // if we're in the middle of a search, then a spacebar should be treated as a search and we should not propagate the event + // since we handle this one in a capture phase, we should ignore it in the bubble phase + if (state.current.search.length > 0 && e.key === ' ') { + e.preventDefault(); + if ( + !('continuePropagation' in e) || + ('continuePropagation' in e && !e.isPropagationStopped()) + ) { + e.stopPropagation(); + } + state.current.search += ' '; + + if (keyboardDelegate.getKeyForSearch != null) { + // Use the delegate to find a key to focus. + // Prioritize items after the currently focused item, falling back to searching the whole list. + let key = keyboardDelegate.getKeyForSearch( + state.current.search, + selectionManager.focusedKey + ); + + // If no key found, search from the top. + if (key == null) { + key = keyboardDelegate.getKeyForSearch(state.current.search); + } + + if (key != null) { + selectionManager.setFocusedKey(key); + if (onTypeSelect) { + onTypeSelect(key); + } + } + } + + clearTimeout(state.current.timeout); + state.current.timeout = setTimeout(() => { + state.current.search = ''; + }, TYPEAHEAD_DEBOUNCE_WAIT_MS); + } + }; let onKeyDown = (e: KeyboardEvent) => { let character = getStringForKey(e.key); if ( !character || + e.altKey || e.ctrlKey || e.metaKey || - !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) || - (state.search.length === 0 && character === ' ') + character === ' ' || + !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) ) { return; } - // Do not propagate the Spacebar event if it's meant to be part of the search. - // When we time out, the search term becomes empty, hence the check on length. - // Trimming is to account for the case of pressing the Spacebar more than once, - // which should cycle through the selection/deselection of the focused item. - if (character === ' ' && state.search.trim().length > 0) { - e.preventDefault(); - if (!('continuePropagation' in e)) { - e.stopPropagation(); - } - } - - state.search += character; + state.current.search += character; if (keyboardDelegate.getKeyForSearch != null) { // Use the delegate to find a key to focus. // Prioritize items after the currently focused item, falling back to searching the whole list. - let key = keyboardDelegate.getKeyForSearch(state.search, selectionManager.focusedKey); + let key = keyboardDelegate.getKeyForSearch(state.current.search, selectionManager.focusedKey); // If no key found, search from the top. if (key == null) { - key = keyboardDelegate.getKeyForSearch(state.search); + key = keyboardDelegate.getKeyForSearch(state.current.search); } if (key != null) { @@ -92,20 +123,38 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { if (onTypeSelect) { onTypeSelect(key); } + e.preventDefault(); + if (!('continuePropagation' in e)) { + e.stopPropagation(); + } + } else { + // if still nothing then the type to select is done and everything is reset + state.current.search = ''; + clearTimeout(state.current.timeout); + state.current.timeout = undefined; + return; } } - clearTimeout(state.timeout); - state.timeout = setTimeout(() => { - state.search = ''; + clearTimeout(state.current.timeout); + state.current.timeout = setTimeout(() => { + state.current.search = ''; }, TYPEAHEAD_DEBOUNCE_WAIT_MS); }; + useEffect(() => { + let timeout = state.current.timeout; + return () => { + clearTimeout(timeout); + }; + }, [state]); + return { typeSelectProps: { // Using a capturing listener to catch the keydown event before // other hooks in order to handle the Spacebar event. - onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined + onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDownCapture : undefined, + onKeyDown: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined } }; } diff --git a/packages/react-aria/src/toolbar/useToolbar.ts b/packages/react-aria/src/toolbar/useToolbar.ts index 00b0b54b28c..5b8011654f7 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,79 @@ export function useToolbar( setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]'))); }); const {direction} = useLocale(); - const shouldReverse = direction === 'rtl' && orientation === 'horizontal'; 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. - e.stopPropagation(); - 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 +162,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 }