Skip to content

Commit

Permalink
feat(react-router): implement hash change "Scroll Into View" option (#…
Browse files Browse the repository at this point in the history
…2996)

* feat: implement hash change scrollIntoView option

* e2e tests for hash change scroll behavior

* update e2e tests to use data-testId

* address feedback

* remove accidental change to package.json

* add anchor link to example navigation

* ci: apply automated fixes

* handle when location transitions without commitLocation running

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
joshuaslate and autofix-ci[bot] authored Dec 14, 2024
1 parent e5f7a8c commit 9214714
Show file tree
Hide file tree
Showing 15 changed files with 759 additions and 5 deletions.
1 change: 1 addition & 0 deletions docs/framework/react/api/router/NavigateOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The `NavigateOptions` type is used to describe the options that can be used when
type NavigateOptions = ToOptions & {
replace?: boolean
resetScroll?: boolean
hashScrollIntoView?: boolean | ScrollIntoViewOptions
ignoreBlocker?: boolean
reloadDocument?: boolean
href?: string
Expand Down
9 changes: 9 additions & 0 deletions docs/framework/react/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@ The `RouterOptions` type accepts an object with the following properties and met
- See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) for more information on how this function works.
- See [Google](https://developer.chrome.com/docs/web-platform/view-transitions/same-document#view-transition-types) for more informations on viewTransition types

### `defaultHashScrollIntoView` property

- Type: `boolean | ScrollIntoViewOptions`
- Optional
- Defaults to `true` so the element with an id matching the hash will be scrolled into view after the location is committed to history.
- If `false`, the element with an id matching the hash will not be scrolled into view after the location is committed to history.
- If an object is provided, it will be passed to the `scrollIntoView` method as options.
- See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) for more information on `ScrollIntoViewOptions`.

### `caseSensitive` property

- Type: `boolean`
Expand Down
8 changes: 8 additions & 0 deletions docs/framework/react/api/router/RouterType.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Commits a new location object to the browser history.
location: ParsedLocation & {
replace?: boolean
resetScroll?: boolean
hashScrollIntoView?: boolean | ScrollIntoViewOptions
ignoreBlocker?: boolean
},
) => Promise<void>
Expand All @@ -112,6 +113,13 @@ Commits a new location object to the browser history.
- Optional
- Defaults to `true` so that the scroll position will be reset to 0,0 after the location is committed to the browser history.
- If `false`, the scroll position will not be reset to 0,0 after the location is committed to history.
- `hashScrollIntoView`
- Type: `boolean | ScrollIntoViewOptions`
- Optional
- Defaults to `true` so the element with an id matching the hash will be scrolled into view after the location is committed to history.
- If `false`, the element with an id matching the hash will not be scrolled into view after the location is committed to history.
- If an object is provided, it will be passed to the `scrollIntoView` method as options.
- See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) for more information on `ScrollIntoViewOptions`.
- `ignoreBlocker`
- Type: `boolean`
- Optional
Expand Down
26 changes: 26 additions & 0 deletions e2e/react-router/basic-file-based/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createFileRoute } from '@tanstack/react-router'

import { Route as rootRoute } from './routes/__root'
import { Route as PostsImport } from './routes/posts'
import { Route as AnchorImport } from './routes/anchor'
import { Route as LayoutImport } from './routes/_layout'
import { Route as IndexImport } from './routes/index'
import { Route as RedirectIndexImport } from './routes/redirect/index'
Expand Down Expand Up @@ -52,6 +53,12 @@ const PostsRoute = PostsImport.update({
getParentRoute: () => rootRoute,
} as any)

const AnchorRoute = AnchorImport.update({
id: '/anchor',
path: '/anchor',
getParentRoute: () => rootRoute,
} as any)

const LayoutRoute = LayoutImport.update({
id: '/_layout',
getParentRoute: () => rootRoute,
Expand Down Expand Up @@ -191,6 +198,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutImport
parentRoute: typeof rootRoute
}
'/anchor': {
id: '/anchor'
path: '/anchor'
fullPath: '/anchor'
preLoaderRoute: typeof AnchorImport
parentRoute: typeof rootRoute
}
'/posts': {
id: '/posts'
path: '/posts'
Expand Down Expand Up @@ -420,6 +434,7 @@ const RedirectTargetRouteWithChildren = RedirectTargetRoute._addFileChildren(
export interface FileRoutesByFullPath {
'/': typeof groupLayoutRouteWithChildren
'': typeof LayoutLayout2RouteWithChildren
'/anchor': typeof AnchorRoute
'/posts': typeof PostsRouteWithChildren
'/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute
'/inside': typeof groupInsideRoute
Expand All @@ -442,6 +457,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof groupLayoutRouteWithChildren
'': typeof LayoutLayout2RouteWithChildren
'/anchor': typeof AnchorRoute
'/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute
'/inside': typeof groupInsideRoute
'/lazyinside': typeof groupLazyinsideRoute
Expand All @@ -463,6 +479,7 @@ export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/_layout': typeof LayoutRouteWithChildren
'/anchor': typeof AnchorRoute
'/posts': typeof PostsRouteWithChildren
'/(another-group)/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute
'/(group)': typeof groupRouteWithChildren
Expand Down Expand Up @@ -490,6 +507,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| ''
| '/anchor'
| '/posts'
| '/onlyrouteinside'
| '/inside'
Expand All @@ -511,6 +529,7 @@ export interface FileRouteTypes {
to:
| '/'
| ''
| '/anchor'
| '/onlyrouteinside'
| '/inside'
| '/lazyinside'
Expand All @@ -530,6 +549,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/_layout'
| '/anchor'
| '/posts'
| '/(another-group)/onlyrouteinside'
| '/(group)'
Expand All @@ -556,6 +576,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LayoutRoute: typeof LayoutRouteWithChildren
AnchorRoute: typeof AnchorRoute
PostsRoute: typeof PostsRouteWithChildren
anotherGroupOnlyrouteinsideRoute: typeof anotherGroupOnlyrouteinsideRoute
groupRoute: typeof groupRouteWithChildren
Expand All @@ -568,6 +589,7 @@ export interface RootRouteChildren {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LayoutRoute: LayoutRouteWithChildren,
AnchorRoute: AnchorRoute,
PostsRoute: PostsRouteWithChildren,
anotherGroupOnlyrouteinsideRoute: anotherGroupOnlyrouteinsideRoute,
groupRoute: groupRouteWithChildren,
Expand All @@ -589,6 +611,7 @@ export const routeTree = rootRoute
"children": [
"/",
"/_layout",
"/anchor",
"/posts",
"/(another-group)/onlyrouteinside",
"/(group)",
Expand All @@ -607,6 +630,9 @@ export const routeTree = rootRoute
"/_layout/_layout-2"
]
},
"/anchor": {
"filePath": "anchor.tsx"
},
"/posts": {
"filePath": "posts.tsx",
"children": [
Expand Down
223 changes: 223 additions & 0 deletions e2e/react-router/basic-file-based/src/routes/anchor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { useLayoutEffect, useRef, useState } from 'react'
import {
Link,
createFileRoute,
useLocation,
useNavigate,
} from '@tanstack/react-router'

export const Route = createFileRoute('/anchor')({
component: AnchorComponent,
})

const anchors: Array<{
id: string
title: string
hashScrollIntoView?: boolean | ScrollIntoViewOptions
}> = [
{
id: 'default-anchor',
title: 'Default Anchor',
},
{
id: 'false-anchor',
title: 'No Scroll Into View',
hashScrollIntoView: false,
},
{
id: 'smooth-scroll',
title: 'Smooth Scroll',
hashScrollIntoView: { behavior: 'smooth' },
},
] as const

function AnchorSection({ id, title }: { id: string; title: string }) {
const [hasShown, setHasShown] = useState(false)
const elementRef = useRef<HTMLHeadingElement>(null)

useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (!hasShown && entry.isIntersecting) {
setHasShown(true)
}
},
{ threshold: 0.01 },
)

const currentRef = elementRef.current
if (currentRef) {
observer.observe(currentRef)
}

return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [hasShown])

return (
<div id={id} className="p-2 min-h-dvh">
<h1
data-testid={`heading-${id}`}
className="font-bold text-xl pt-10"
ref={elementRef}
>
{title}
{hasShown ? ' (shown)' : ''}
</h1>
</div>
)
}

function AnchorComponent() {
const navigate = useNavigate()
const location = useLocation()
const [withScroll, setWithScroll] = useState(true)

return (
<div className="flex flex-col w-full">
<nav className="sticky top-0 z-10 p-2 bg-gray-50 dark:bg-gray-900 border-b">
<ul className="inline-flex gap-2">
{anchors.map((anchor) => (
<li key={anchor.id}>
<Link
data-testid={`link-${anchor.id}`}
hash={anchor.id}
activeOptions={{ includeHash: true }}
activeProps={{
className: 'font-bold active',
}}
hashScrollIntoView={anchor.hashScrollIntoView}
>
{anchor.title}
</Link>
</li>
))}
</ul>
</nav>
<main className="overflow-auto">
<form
className="p-2 space-y-2 min-h-dvh"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
const formData = new FormData(event.target as HTMLFormElement)

const toHash = formData.get('hash') as string

if (!toHash) {
return
}

const hashScrollIntoView = withScroll
? ({
behavior: formData.get('scrollBehavior') as ScrollBehavior,
block: formData.get('scrollBlock') as ScrollLogicalPosition,
inline: formData.get('scrollInline') as ScrollLogicalPosition,
} satisfies ScrollIntoViewOptions)
: false

navigate({ hash: toHash, hashScrollIntoView })
}}
>
<h1 className="font-bold text-xl">Scroll with navigate</h1>
<div className="space-y-2">
<label>
<span>Target Anchor</span>
<select
className="border border-opacity-50 rounded p-2 w-full"
data-testid="hash-select"
defaultValue={location.hash || anchors[0].id}
name="hash"
>
{anchors.map((anchor) => (
<option key={anchor.id} value={anchor.id}>
{anchor.title}
</option>
))}
</select>
</label>
<div>
<label>
<input
checked={withScroll}
data-testid="with-scroll"
onChange={(e) => setWithScroll(e.target.checked)}
type="checkbox"
/>{' '}
Scroll Into View
</label>
</div>
</div>
{withScroll ? (
<>
<div className="space-y-2">
<label>
<span>Behavior</span>
<select
className="border border-opacity-50 rounded p-2 w-full"
data-testid="behavior-select"
defaultValue="instant"
name="scrollBehavior"
>
<option value="instant">instant</option>
<option value="smooth">smooth</option>
<option value="auto">auto</option>
</select>
</label>
</div>

<div className="space-y-2">
<label>
<span>Block</span>
<select
className="border border-opacity-50 rounded p-2 w-full"
data-testid="block-select"
defaultValue="start"
name="scrollBlock"
>
<option value="start">start</option>
<option value="center">center</option>
<option value="end">end</option>
<option value="nearest">nearest</option>
</select>
</label>
</div>

<div className="space-y-2">
<label>
<span>Inline</span>
<select
className="border border-opacity-50 rounded p-2 w-full"
data-testid="inline-select"
defaultValue="nearest"
name="scrollInline"
>
<option value="start">start</option>
<option value="center">center</option>
<option value="end">end</option>
<option value="nearest">nearest</option>
</select>
</label>
</div>
</>
) : null}
<div>
<button
className="bg-blue-500 rounded p-2 uppercase text-white font-black disabled:opacity-50"
data-testid="navigate-button"
>
Navigate
</button>
</div>
</form>

{anchors.map((anchor) => (
<AnchorSection key={anchor.id} id={anchor.id} title={anchor.title} />
))}
</main>
</div>
)
}
Loading

0 comments on commit 9214714

Please sign in to comment.