Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/react-aria-components/test/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<Table aria-label="Files">
<MyTableHeader columns={columns}>
{column => (
<MyColumn isRowHeader={column.isRowHeader} childColumns={column.children}>
{column.name}
</MyColumn>
)}
</MyTableHeader>
<TableBody items={rows}>
{item => (
<MyRow columns={columns} textValue={item.textValue}>
{column => {
if (column.id !== 'name') {
return (
<Cell>
<div>{item[column.id]}</div>
</Cell>
);
}
return (
<Cell>
<div>
{item[column.id]}
<Select aria-label="Select">
<Button>
<SelectValue />
</Button>
<Popover>
<ListBox>
<ListBoxItem>abc</ListBoxItem>
<ListBoxItem>boo</ListBoxItem>
<ListBoxItem>ghi</ListBoxItem>
</ListBox>
</Popover>
</Select>
</div>
</Cell>
);
}}
</MyRow>
)}
</TableBody>
</Table>
);
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(
<DynamicTable
Expand Down
18 changes: 12 additions & 6 deletions packages/react-aria/src/actiongroup/useActionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,24 +95,30 @@ export function useActionGroup<T>(
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;
}
}
});
Expand Down
1 change: 1 addition & 0 deletions packages/react-aria/src/dnd/DragManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ class DragSession {
}

onKeyDown(e: KeyboardEvent): void {
// TODO: should these be stopped?
this.cancelEvent(e);

if (e.key === 'Escape') {
Expand Down
26 changes: 26 additions & 0 deletions packages/react-aria/src/focus/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 71 additions & 43 deletions packages/react-aria/src/toolbar/useToolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<HTMLElement | null>(null);
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unable to let children toolbars manage themselves at the moment, this is because in a nested case, tab/shift+tab needs to go to the last focus element across everything in the nested toolbars

Arrow keys would all work, it's just the overall management. I could remove tab/shift tab from useKeyboard and just gate that part on this logic

onKeyUp: !isInToolbar ? keyboardProps.onKeyUp : undefined,
onFocusCapture: !isInToolbar ? onFocus : undefined,
onBlurCapture: !isInToolbar ? onBlur : undefined
}
Expand Down