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
27 changes: 17 additions & 10 deletions packages/@react-aria/utils/src/scrollIntoView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ interface ScrollIntoViewOpts {
/** The position to align items along the block axis in. */
block?: ScrollLogicalPosition,
/** The position to align items along the inline axis in. */
inline?: ScrollLogicalPosition
inline?: ScrollLogicalPosition,
/** The behavior to determine whether scrolling should animate smoothly or happen instantly. */
behavior?: ScrollIntoViewOptions['behavior']
}


interface ScrollIntoViewportOpts {
/** The optional containing element of the target to be centered in the viewport. */
containingElement?: Element | null
containingElement?: Element | null,
/** The optional alignment of the target element within the viewport. */
block?: ScrollIntoViewOptions['block'],
/** The optional behavior that determines whether scrolling is instant or animates smoothly. */
behavior?: ScrollIntoViewOptions['behavior']
}

/**
Expand All @@ -32,7 +38,7 @@ interface ScrollIntoViewportOpts {
* but doesn't affect parents above `scrollView`.
*/
export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, opts: ScrollIntoViewOpts = {}): void {
let {block = 'nearest', inline = 'nearest'} = opts;
let {block = 'nearest', inline = 'nearest', behavior = 'auto'} = opts;
Copy link
Author

Choose a reason for hiding this comment

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

As per specs, the default value of behavior is auto


if (scrollView === element) { return; }

Expand Down Expand Up @@ -120,16 +126,17 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, op
return;
}

scrollView.scrollTo({left: x, top: y});
scrollView.scrollTo({left: x, top: y, behavior});
}

/**
* Scrolls the `targetElement` so it is visible in the viewport. Accepts an optional `opts.containingElement`
* that will be centered in the viewport prior to scrolling the targetElement into view. If scrolling is prevented on
* that will be centered in the viewport prior to scrolling the targetElement into view, as well as optional `opts.block` and `opts.behavior`
* to determine the alignment and animation behavior of the target element. If scrolling is prevented on
* the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself.
*/
export function scrollIntoViewport(targetElement: Element | null, opts: ScrollIntoViewportOpts = {}): void {
let {containingElement} = opts;
let {containingElement, block = 'nearest', behavior = 'auto'} = opts;
if (targetElement && targetElement.isConnected) {
let root = document.scrollingElement || document.documentElement;
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
Expand All @@ -140,27 +147,27 @@ export function scrollIntoViewport(targetElement: Element | null, opts: ScrollIn

// use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()
// won't cause a scroll if the element is already focused and doesn't behave consistently when an element is partially out of view horizontally vs vertically
targetElement?.scrollIntoView?.({block: 'nearest'});
targetElement?.scrollIntoView?.({block, behavior});
let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
// Account for sub pixel differences from rounding
if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
containingElement?.scrollIntoView?.({block: 'center', inline: 'center'});
targetElement.scrollIntoView?.({block: 'nearest'});
targetElement.scrollIntoView?.({block, behavior});
}
} else {
let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();

// If scrolling is prevented, we don't want to scroll the body since it might move the overlay partially offscreen and the user can't scroll it back into view.
let scrollParents = getScrollParents(targetElement, true);
for (let scrollParent of scrollParents) {
scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement);
scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement, {block: opts.block || 'start', behavior});
Copy link
Author

Choose a reason for hiding this comment

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

As per specs, the default value of block is start. Since this call didn't have block, I short-circuited the opts object directly instead of using the nearest default value from the destructuring at the beginning of this function. I needed to provide the block value here as well to preserve the bottom padding after scrolling finishes.

}
let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
// Account for sub pixel differences from rounding
if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
scrollParents = containingElement ? getScrollParents(containingElement, true) : [];
for (let scrollParent of scrollParents) {
scrollIntoView(scrollParent as HTMLElement, containingElement as HTMLElement, {block: 'center', inline: 'center'});
scrollIntoView(scrollParent as HTMLElement, containingElement as HTMLElement, {block: opts.block || 'center', inline: 'center', behavior});
Copy link
Author

Choose a reason for hiding this comment

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

Same decision on short-circuiting: if opts.block is not provided (existing logic), then it passes the {block: center}, thus not breaking any existing functionality that depends on calling this function.

}
}
}
Expand Down
16 changes: 13 additions & 3 deletions packages/dev/s2-docs/src/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {getLibraryFromPage} from './library';
import LinkOutIcon from '../../../@react-spectrum/s2/ui-icons/LinkOut';
import type {Page} from '@parcel/rsc';
import React, {createContext, useContext, useEffect, useRef, useState} from 'react';
import {scrollIntoViewport} from '@react-aria/utils';
import {usePendingPage, useRouter} from './Router';

type SectionValue = Page[] | Map<string, Page[]>;
Expand Down Expand Up @@ -188,7 +189,7 @@ export function Nav() {
);
}
return (
<Disclosure id={name} key={name} isQuiet density="spacious" defaultExpanded={name === 'Components' || name === currentPage.exports?.section} styles={style({minWidth: 185})}>
<Disclosure id={name} key={name} isQuiet density="spacious" defaultExpanded={name === 'Components' || name === currentPage.exports?.section || name === currentPage.exports?.group} styles={style({minWidth: 185})}>
<DisclosureTitle>{name}</DisclosureTitle>
<DisclosurePanel>
<div className={style({paddingStart: space(18)})}>{nav}</div>
Expand Down Expand Up @@ -258,7 +259,15 @@ export function SideNavLink(props) {
let linkRef = useRef(null);
let selected = useContext(SideNavContext);
let {isExternal, ...linkProps} = props;


useEffect(() => {
if (!linkRef.current || !props.isSelected) {
return;
}

scrollIntoViewport(linkRef.current, {block: 'start', behavior: 'smooth'});
}, [props.isSelected]);

return (
<BaseLink
{...linkProps}
Expand All @@ -283,7 +292,8 @@ export function SideNavLink(props) {
},
textDecoration: 'none',
borderRadius: 'default',
transition: 'default'
transition: 'default',
scrollMarginTop: 64
})}>
{(renderProps) => (<>
<span
Expand Down