diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index d27a260bb43..d11d1c682ad 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -49,7 +49,9 @@ export interface GridProps extends DOMProps, AriaLabelingProps { export interface GridAria { /** Props for the grid element. */ - gridProps: HTMLAttributes + gridProps: HTMLAttributes, + /** Props for the grid's scrollable region. */ + scrollBodyProps: HTMLAttributes } /** @@ -85,7 +87,7 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState + collectionProps: HTMLAttributes, + /** Props for the collection element's scrollable region. */ + scrollBodyProps: HTMLAttributes } /** @@ -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) => { + // 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) => { + // 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, @@ -377,6 +471,9 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S collectionProps: { ...handlers, tabIndex + }, + scrollBodyProps: { + onScroll } }; } diff --git a/packages/@react-aria/table/src/useTable.ts b/packages/@react-aria/table/src/useTable.ts index cb9510a7662..72ebcc8acfa 100644 --- a/packages/@react-aria/table/src/useTable.ts +++ b/packages/@react-aria/table/src/useTable.ts @@ -60,7 +60,7 @@ export function useTable(props: TableProps, state: TableState, ref: Ref let id = useId(); gridIds.set(state, id); - let {gridProps} = useGrid({ + let {gridProps, scrollBodyProps} = useGrid({ ...props, id, keyboardDelegate: delegate, @@ -114,6 +114,7 @@ export function useTable(props: TableProps, state: TableState, ref: Ref }, [sortDescription]); return { - gridProps: mergeProps(gridProps, descriptionProps) + gridProps: mergeProps(gridProps, descriptionProps), + scrollBodyProps }; } diff --git a/packages/@react-aria/table/stories/example.tsx b/packages/@react-aria/table/stories/example.tsx index e426d793589..7337f49c117 100644 --- a/packages/@react-aria/table/stories/example.tsx +++ b/packages/@react-aria/table/stories/example.tsx @@ -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 ( - + {collection.headerRows.map(headerRow => ( {[...headerRow.childNodes].map(column => @@ -40,7 +40,7 @@ export function Table(props) { ))} - + {[...collection.body.childNodes].map(row => ( {[...row.childNodes].map(cell => @@ -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 ( - + {children} ); diff --git a/packages/@react-aria/table/stories/useTable.stories.tsx b/packages/@react-aria/table/stories/useTable.stories.tsx index cb6bec37c15..a47939d9f06 100644 --- a/packages/@react-aria/table/stories/useTable.stories.tsx +++ b/packages/@react-aria/table/stories/useTable.stories.tsx @@ -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 = {}) => ( <> - + {!args.hideInput && }
{column => ( @@ -60,9 +62,12 @@ const Template = () => () => ( )}
- + {!args.hideInput && } ); export const ScrollTesting = Template().bind({}); ScrollTesting.args = {}; + +export const ScrollTestingTabFromBrowser = Template().bind({}); +ScrollTestingTabFromBrowser.args = {hideInput: true};