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
}