From 14345d86d21975efefa57e7cf94181ca06ef1cf0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 19 Aug 2021 11:45:00 -0700 Subject: [PATCH 01/11] moving useSelectableList scrolling code to useSelectableCollection allows non virtualized tables/grids created with useTable/useGrid to have automatic scrolling when moving focus via arrow keys --- .../@react-aria/combobox/src/useComboBox.ts | 4 +- packages/@react-aria/grid/src/useGrid.ts | 3 +- .../selection/src/useSelectableCollection.ts | 74 ++++++++++++++++++- .../selection/src/useSelectableList.ts | 68 +---------------- packages/@react-aria/tabs/src/useTabList.ts | 6 +- packages/@react-types/tabs/src/index.d.ts | 7 +- 6 files changed, 89 insertions(+), 73 deletions(-) diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index eebbd093ffb..a08460b27b4 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -94,7 +94,9 @@ export function useComboBox(props: AriaComboBoxProps, state: ComboBoxState keyboardDelegate: delegate, disallowTypeAhead: true, disallowEmptySelection: true, - ref: inputRef + ref: inputRef, + // Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component + isVirtualized: true }); // For textfield specific keydown operations diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 8c1555a5633..d0407baff76 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -83,7 +83,8 @@ export function useGrid(props: GridProps, state: GridState { + if (!isVirtualized && manager.focusedKey && ref?.current) { + let element = ref.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement; + if (element) { + scrollIntoView(ref.current, element); + } + } + }, [isVirtualized, ref, manager.focusedKey]); + let handlers = { onKeyDown, onFocus, @@ -357,3 +373,57 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } }; } + +/** + * Scrolls `scrollView` so that `element` is visible. + * Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge), + * but doesn't affect parents above `scrollView`. + */ + function scrollIntoView(scrollView: HTMLElement, element: HTMLElement) { + let offsetX = relativeOffset(scrollView, element, 'left'); + let offsetY = relativeOffset(scrollView, element, 'top'); + let width = element.offsetWidth; + let height = element.offsetHeight; + let x = scrollView.scrollLeft; + let y = scrollView.scrollTop; + let maxX = x + scrollView.offsetWidth; + let maxY = y + scrollView.offsetHeight; + + if (offsetX <= x) { + x = offsetX; + } else if (offsetX + width > maxX) { + x += offsetX + width - maxX; + } + if (offsetY <= y) { + y = offsetY; + } else if (offsetY + height > maxY) { + y += offsetY + height - maxY; + } + + scrollView.scrollLeft = x; + scrollView.scrollTop = y; +} + +/** + * Computes the offset left or top from child to ancestor by accumulating + * offsetLeft or offsetTop through intervening offsetParents. + */ + function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') { + const prop = axis === 'left' ? 'offsetLeft' : 'offsetTop'; + let sum = 0; + while (child.offsetParent) { + sum += child[prop]; + if (child.offsetParent === ancestor) { + // Stop once we have found the ancestor we are interested in. + break; + } else if (child.offsetParent.contains(ancestor)) { + // If the ancestor is not `position:relative`, then we stop at + // _its_ offset parent, and we subtract off _its_ offset, so that + // we end up with the proper offset from child to ancestor. + sum -= ancestor[prop]; + break; + } + child = child.offsetParent as HTMLElement; + } + return sum; +} diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts index e64290276cb..ce4d107c135 100644 --- a/packages/@react-aria/selection/src/useSelectableList.ts +++ b/packages/@react-aria/selection/src/useSelectableList.ts @@ -109,17 +109,6 @@ export function useSelectableList(props: SelectableListOptions): SelectableListA let collator = useCollator({usage: 'search', sensitivity: 'base'}); let delegate = useMemo(() => keyboardDelegate || new ListKeyboardDelegate(collection, disabledKeys, ref, collator), [keyboardDelegate, collection, disabledKeys, ref, collator]); - // If not virtualized, scroll the focused element into view when the focusedKey changes. - // When virtualized, Virtualizer handles this internally. - useEffect(() => { - if (!isVirtualized && selectionManager.focusedKey && ref?.current) { - let element = ref.current.querySelector(`[data-key="${selectionManager.focusedKey}"]`) as HTMLElement; - if (element) { - scrollIntoView(ref.current, element); - } - } - }, [isVirtualized, ref, selectionManager.focusedKey]); - let {collectionProps} = useSelectableCollection({ ref, selectionManager, @@ -130,64 +119,11 @@ export function useSelectableList(props: SelectableListOptions): SelectableListA selectOnFocus, disallowTypeAhead, shouldUseVirtualFocus, - allowsTabNavigation + allowsTabNavigation, + isVirtualized }); return { listProps: collectionProps }; } - -/** - * Scrolls `scrollView` so that `element` is visible. - * Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge), - * but doesn't affect parents above `scrollView`. - */ -function scrollIntoView(scrollView: HTMLElement, element: HTMLElement) { - let offsetX = relativeOffset(scrollView, element, 'left'); - let offsetY = relativeOffset(scrollView, element, 'top'); - let width = element.offsetWidth; - let height = element.offsetHeight; - let x = scrollView.scrollLeft; - let y = scrollView.scrollTop; - let maxX = x + scrollView.offsetWidth; - let maxY = y + scrollView.offsetHeight; - - if (offsetX <= x) { - x = offsetX; - } else if (offsetX + width > maxX) { - x += offsetX + width - maxX; - } - if (offsetY <= y) { - y = offsetY; - } else if (offsetY + height > maxY) { - y += offsetY + height - maxY; - } - - scrollView.scrollLeft = x; - scrollView.scrollTop = y; -} - -/** - * Computes the offset left or top from child to ancestor by accumulating - * offsetLeft or offsetTop through intervening offsetParents. - */ -function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') { - const prop = axis === 'left' ? 'offsetLeft' : 'offsetTop'; - let sum = 0; - while (child.offsetParent) { - sum += child[prop]; - if (child.offsetParent === ancestor) { - // Stop once we have found the ancestor we are interested in. - break; - } else if (child.offsetParent.contains(ancestor)) { - // If the ancestor is not `position:relative`, then we stop at - // _its_ offset parent, and we subtract off _its_ offset, so that - // we end up with the proper offset from child to ancestor. - sum -= ancestor[prop]; - break; - } - child = child.offsetParent as HTMLElement; - } - return sum; -} diff --git a/packages/@react-aria/tabs/src/useTabList.ts b/packages/@react-aria/tabs/src/useTabList.ts index 52ef4df1ecd..cf352166ddc 100644 --- a/packages/@react-aria/tabs/src/useTabList.ts +++ b/packages/@react-aria/tabs/src/useTabList.ts @@ -32,7 +32,8 @@ interface TabListAria { export function useTabList(props: AriaTabListProps, state: TabListState, ref: RefObject): TabListAria { let { orientation = 'horizontal', - keyboardActivation = 'automatic' + keyboardActivation = 'automatic', + isVirtualized } = props; let { collection, @@ -51,7 +52,8 @@ export function useTabList(props: AriaTabListProps, state: TabListState selectionManager: manager, keyboardDelegate: delegate, selectOnFocus: keyboardActivation === 'automatic', - disallowEmptySelection: true + disallowEmptySelection: true, + isVirtualized }); // Compute base id for all tabs diff --git a/packages/@react-types/tabs/src/index.d.ts b/packages/@react-types/tabs/src/index.d.ts index d23598a2b2e..cfa58cee3c4 100644 --- a/packages/@react-types/tabs/src/index.d.ts +++ b/packages/@react-types/tabs/src/index.d.ts @@ -48,7 +48,12 @@ interface AriaTabListBase { isDisabled?: boolean } -export interface AriaTabListProps extends TabListProps, AriaTabListBase, DOMProps, AriaLabelingProps {} +export interface AriaTabListProps extends TabListProps, AriaTabListBase, DOMProps, AriaLabelingProps { + /** + * Whether the TabList is contained in a virtual scroller. + */ + isVirtualized?: boolean +} export interface AriaTabPanelProps extends DOMProps, AriaLabelingProps {} From 33026c531c9ce228bdccc7fe442c0c9fe7110041 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 19 Aug 2021 15:17:18 -0700 Subject: [PATCH 02/11] adding stories and fixing useSelectableCollection scroll behavior some components will have a ref that is not the same element as the scrollable region so add a prop for scrollRef --- .../@react-aria/combobox/docs/useComboBox.mdx | 4 +- .../@react-aria/combobox/stories/example.tsx | 174 +++++++++++++++++ .../combobox/stories/useComboBox.stories.tsx | 35 ++++ packages/@react-aria/grid/src/useGrid.ts | 12 +- .../selection/src/useSelectableCollection.ts | 17 +- .../selection/src/useSelectableList.ts | 5 +- .../@react-aria/table/stories/example.tsx | 183 ++++++++++++++++++ .../table/stories/useTable.stories.tsx | 68 +++++++ packages/@react-aria/tabs/docs/useTabList.mdx | 4 +- packages/@react-aria/tabs/src/useTabList.ts | 3 +- packages/@react-aria/tabs/stories/example.tsx | 49 +++++ .../tabs/stories/useTabList.stories.tsx | 39 ++++ 12 files changed, 577 insertions(+), 16 deletions(-) create mode 100644 packages/@react-aria/combobox/stories/example.tsx create mode 100644 packages/@react-aria/combobox/stories/useComboBox.stories.tsx create mode 100644 packages/@react-aria/table/stories/example.tsx create mode 100644 packages/@react-aria/table/stories/useTable.stories.tsx create mode 100644 packages/@react-aria/tabs/stories/example.tsx create mode 100644 packages/@react-aria/tabs/stories/useTabList.stories.tsx diff --git a/packages/@react-aria/combobox/docs/useComboBox.mdx b/packages/@react-aria/combobox/docs/useComboBox.mdx index a63ea097507..1d8f315e914 100644 --- a/packages/@react-aria/combobox/docs/useComboBox.mdx +++ b/packages/@react-aria/combobox/docs/useComboBox.mdx @@ -303,7 +303,9 @@ function ListBox(props) { style={{ margin: 0, padding: 0, - listStyle: "none" + listStyle: "none", + maxHeight: "150px", + overflow: "auto" }}> {[...state.collection].map(item => (