Skip to content
Closed
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
9 changes: 6 additions & 3 deletions packages/@react-aria/grid/src/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export interface GridProps extends DOMProps, AriaLabelingProps {

export interface GridAria {
/** Props for the grid element. */
gridProps: HTMLAttributes<HTMLElement>
gridProps: HTMLAttributes<HTMLElement>,
/** Props for the grid's scrollable region. */
scrollBodyProps: HTMLAttributes<HTMLElement>
}

/**
Expand Down Expand Up @@ -85,7 +87,7 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
collator,
focusMode
}), [keyboardDelegate, state.collection, state.disabledKeys, ref, direction, collator, focusMode]);
let {collectionProps} = useSelectableCollection({
let {collectionProps, scrollBodyProps} = useSelectableCollection({
ref,
selectionManager: state.selectionManager,
keyboardDelegate: delegate,
Expand Down Expand Up @@ -153,7 +155,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
}, [selection]);

return {
gridProps
gridProps,
scrollBodyProps
};
}

Expand Down
101 changes: 99 additions & 2 deletions packages/@react-aria/selection/src/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {FocusEvent, HTMLAttributes, Key, KeyboardEvent, RefObject, useEffect, useRef} from 'react';
import {FocusEvent, HTMLAttributes, Key, KeyboardEvent, RefObject, useCallback, useEffect, useRef} from 'react';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {FocusStrategy, KeyboardDelegate} from '@react-types/shared';
import {focusWithoutScrolling, isMac, mergeProps} from '@react-aria/utils';
Expand Down Expand Up @@ -90,7 +90,9 @@ interface SelectableCollectionOptions {

interface SelectableCollectionAria {
/** Props for the collection element. */
collectionProps: HTMLAttributes<HTMLElement>
collectionProps: HTMLAttributes<HTMLElement>,
/** Props for the collection element's scrollable region. */
scrollBodyProps: HTMLAttributes<HTMLElement>
}

/**
Expand Down Expand Up @@ -342,6 +344,98 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
}
}, [isVirtualized, scrollRef, manager.focusedKey]);

let prevScroll = useRef({scrollTop: scrollRef?.current?.scrollTop || 0, scrollLeft: scrollRef?.current?.scrollLeft || 0});
// Bring focused key into view if focus enters the collection from outside. Only for non virtualized collections,
// virtualized collections should handle this in their virtualizer or get this for free via our Virtualizer component
useEffect(() => {
let onFocus = (e) => {
Comment on lines +350 to +351
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.

Problem with this approach: if the user focuses a cell -> scrolls so that it is out of view -> moves focus out of the table -> clicks on the table column header -> the table's scrollable region will scroll to bring the previous cell into view. This happens because this capturing listener triggers before useGridCell/useSelectableItem's onFocus can make the column header the new focusedKey, thus this block thinks it needs to scroll the old cell into view.

Not sure what to do here

// Note: we can get away with only checking if the target is in the scrollable region only because the keydown capturing listener below
// will prevent tab/shift tab from focusing the select all checkbox and instead focus the last focused key.
if ((e.target instanceof HTMLElement && scrollRef.current?.contains(e.target)) && !isVirtualized && !manager.isFocused) {
if (manager.focusedKey) {
let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement;
if (element) {
// Figure out if element is out of view
let scrollContainerTop = scrollRef.current.offsetTop + prevScroll.current.scrollTop;
let scrollContainerBottom = scrollRef.current.offsetTop + prevScroll.current.scrollTop + scrollRef.current.offsetHeight;
let scrollContainerLeft = scrollRef.current.offsetLeft + prevScroll.current.scrollLeft;
let scrollContainerRight = scrollRef.current.offsetLeft + prevScroll.current.scrollLeft + scrollRef.current.offsetWidth;
let elementTop = element.offsetTop;
let elementBottom = element.offsetTop + element.clientHeight;
let elementLeft = element.offsetLeft;
let elementRight = element.offsetLeft + element.clientWidth;

let elementOutOfView = elementTop <= scrollContainerTop || elementBottom >= scrollContainerBottom || elementRight >= scrollContainerRight || elementLeft <= scrollContainerLeft;
// If focused key is out of view and is in the scrollable region, scroll it into view when re-entering the table
if (scrollRef.current?.contains(element) && elementOutOfView) {
scrollIntoView(scrollRef.current, element);
} else {
// If focusedkey is already in view, override the scroll that may happen from shift tabbing (browser will focus last focusable element in the table which may be out of view, causing a scroll)
scrollRef.current.scrollTop = prevScroll.current.scrollTop;
scrollRef.current.scrollLeft = prevScroll.current.scrollLeft;
}
}
}
}
};

window.addEventListener('focus', onFocus, true);
return () => {
window.removeEventListener('focus', onFocus, true);
};
}, [manager.focusedKey, scrollRef, isVirtualized]);

// Save the previous scroll position
let onScroll = useCallback(() => {
prevScroll.current = {scrollTop: scrollRef.current.scrollTop, scrollLeft: scrollRef.current.scrollLeft};
}, [scrollRef]);

useEffect(() => {
let onKeyDown = (e) => {
Comment on lines +393 to +394
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.

This block was added to block the scrollable region from scrolling since shift tabbing back into the table will focus the last checkbox in the table before it shifts focus back to the tableview's focused key -> causes a scroll to happen -> messes up the calculation of scrollIntoView.

Problem is that this doesn't quite work when tabbing from outside the window (e.g. browser bar)...

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.

There also exists a bug for non-virtualized tables where tabbing from the browser bar to the table will focus the select all checkbox -> causes the focused key to update, overwriting the previously focused key if one existed.
https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/grid/src/useGridCell.ts#L200-L202 is supposed to stop cases like that but isFocusVisible doesn't return true when tabbing from the browser bar. This is because https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/interactions/src/useFocusVisible.ts#L132 won't capture the tab keydown from the browser bar to the table since it is outside the document

// The consts here can be removed if we export them from FocusScope
const focusableElements = [
'input:not([disabled]):not([type=hidden])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'a[href]',
'area[href]',
'summary',
'iframe',
'object',
'embed',
'audio[controls]',
'video[controls]',
'[contenteditable]'
];

focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])');
const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),');

if (e.key === 'Tab' && !ref.current.contains(e.target as HTMLElement) && !isVirtualized) {
let tabbableElements = Array.from(document.querySelectorAll(TABBABLE_ELEMENT_SELECTOR));
let index = tabbableElements.findIndex(node => node === e.target);
let nodeToFocus;
if (e.shiftKey) {
nodeToFocus = tabbableElements[index - 1];
} else {
nodeToFocus = tabbableElements[index + 1];
}

if (ref.current.contains(nodeToFocus) && manager.focusedKey) {
e.preventDefault();
let element = ref.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement;
element && focusSafely(element);
}
}
};

window.addEventListener('keydown', onKeyDown, true);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
};
}, [manager.focusedKey, ref, isVirtualized]);

let handlers = {
onKeyDown,
onFocus,
Expand Down Expand Up @@ -377,6 +471,9 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
collectionProps: {
...handlers,
tabIndex
},
scrollBodyProps: {
onScroll
}
};
}
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-aria/table/src/useTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function useTable<T>(props: TableProps<T>, state: TableState<T>, ref: Ref
let id = useId();
gridIds.set(state, id);

let {gridProps} = useGrid({
let {gridProps, scrollBodyProps} = useGrid({
...props,
id,
keyboardDelegate: delegate,
Expand Down Expand Up @@ -114,6 +114,7 @@ export function useTable<T>(props: TableProps<T>, state: TableState<T>, ref: Ref
}, [sortDescription]);

return {
gridProps: mergeProps(gridProps, descriptionProps)
gridProps: mergeProps(gridProps, descriptionProps),
scrollBodyProps
};
}
10 changes: 5 additions & 5 deletions packages/@react-aria/table/stories/example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ export function Table(props) {
let ref = useRef();
let bodyRef = useRef();
let {collection} = state;
let {gridProps} = useTable({...props, scrollRef: bodyRef}, state, ref);
let {gridProps, scrollBodyProps} = useTable({...props, scrollRef: bodyRef}, state, ref);

return (
<table {...gridProps} ref={ref} style={{borderCollapse: 'collapse'}}>
<TableRowGroup type="thead" style={{borderBottom: '2px solid gray', display: 'block'}}>
<TableRowGroup type="thead" style={{borderBottom: '2px solid gray', display: 'block', maxWidth: '200px', overflow: 'auto'}}>
{collection.headerRows.map(headerRow => (
<TableHeaderRow key={headerRow.key} item={headerRow} state={state}>
{[...headerRow.childNodes].map(column =>
Expand All @@ -40,7 +40,7 @@ export function Table(props) {
</TableHeaderRow>
))}
</TableRowGroup>
<TableRowGroup ref={bodyRef} type="tbody" style={{display: 'block', overflow: 'auto', maxHeight: '200px'}}>
<TableRowGroup ref={bodyRef} type="tbody" style={{display: 'block', overflow: 'auto', maxHeight: '200px', maxWidth: '200px'}} {...scrollBodyProps}>
{[...collection.body.childNodes].map(row => (
<TableRow key={row.key} item={row} state={state}>
{[...row.childNodes].map(cell =>
Expand All @@ -56,10 +56,10 @@ export function Table(props) {
}

const TableRowGroup = React.forwardRef((props: any, ref) => {
let {type: Element, style, children} = props;
let {type: Element, style, children, onScroll} = props;
let {rowGroupProps} = useTableRowGroup();
return (
<Element ref={ref} {...rowGroupProps} style={style}>
<Element ref={ref} onScroll={onScroll} {...rowGroupProps} style={style}>
{children}
</Element>
);
Expand Down
37 changes: 21 additions & 16 deletions packages/@react-aria/table/stories/useTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,29 @@ export default meta;
let columns = [
{name: 'Name', uid: 'name'},
{name: 'Type', uid: 'type'},
{name: 'Level', uid: 'level'}
{name: 'Level', uid: 'level'},
{name: 'Filler', uid: 'filler'},
{name: 'Blah', uid: 'blah'}
];

let rows = [
{id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67'},
{id: 2, name: 'Blastoise', type: 'Water', level: '56'},
{id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83'},
{id: 4, name: 'Pikachu', type: 'Electric', level: '100'},
{id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67'},
{id: 6, name: 'Blastoise', type: 'Water', level: '56'},
{id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83'},
{id: 8, name: 'Pikachu', type: 'Electric', level: '100'},
{id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67'},
{id: 10, name: 'Blastoise', type: 'Water', level: '56'},
{id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83'},
{id: 12, name: 'Pikachu', type: 'Electric', level: '100'}
{id: 1, name: 'Charizard', type: 'Fire, Flying', level: '67', filler: 'filler here', blah: 'blah here'},
{id: 2, name: 'Blastoise', type: 'Water', level: '56', filler: 'filler here', blah: 'blah here'},
{id: 3, name: 'Venusaur', type: 'Grass, Poison', level: '83', filler: 'filler here', blah: 'blah here'},
{id: 4, name: 'Pikachu', type: 'Electric', level: '100', filler: 'filler here', blah: 'blah here'},
{id: 5, name: 'Charizard', type: 'Fire, Flying', level: '67', filler: 'filler here', blah: 'blah here'},
{id: 6, name: 'Blastoise', type: 'Water', level: '56', filler: 'filler here', blah: 'blah here'},
{id: 7, name: 'Venusaur', type: 'Grass, Poison', level: '83', filler: 'filler here', blah: 'blah here'},
{id: 8, name: 'Pikachu', type: 'Electric', level: '100', filler: 'filler here', blah: 'blah here'},
{id: 9, name: 'Charizard', type: 'Fire, Flying', level: '67', filler: 'filler here', blah: 'blah here'},
{id: 10, name: 'Blastoise', type: 'Water', level: '56', filler: 'filler here', blah: 'blah here'},
{id: 11, name: 'Venusaur', type: 'Grass, Poison', level: '83', filler: 'filler here', blah: 'blah here'},
{id: 12, name: 'Pikachu', type: 'Electric', level: '100', filler: 'filler here', blah: 'blah here'}
];

const Template = () => () => (
const Template = () => (args: any = {}) => (
<>
<input aria-label="Focusable before" placeholder="Focusable before" />
{!args.hideInput && <input aria-label="Focusable before" placeholder="Focusable before" />}
<Table aria-label="Table with selection" selectionMode="multiple">
<TableHeader columns={columns}>
{column => (
Expand All @@ -60,9 +62,12 @@ const Template = () => () => (
)}
</TableBody>
</Table>
<input aria-label="Focusable after" placeholder="Focusable after" />
{!args.hideInput && <input aria-label="Focusable after" placeholder="Focusable after" />}
</>
);

export const ScrollTesting = Template().bind({});
ScrollTesting.args = {};

export const ScrollTestingTabFromBrowser = Template().bind({});
ScrollTestingTabFromBrowser.args = {hideInput: true};