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
}