Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-router): implement hash change "Scroll Into View" option #2996

Merged
Merged
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
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
}
```
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 PostsIndexImport } from './routes/posts.index'
Expand Down Expand Up @@ -47,6 +48,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 @@ -155,6 +162,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 @@ -333,6 +347,7 @@ const groupRouteWithChildren = groupRoute._addFileChildren(groupRouteChildren)
export interface FileRoutesByFullPath {
'/': typeof groupLayoutRouteWithChildren
'': typeof LayoutLayout2RouteWithChildren
'/anchor': typeof AnchorRoute
'/posts': typeof PostsRouteWithChildren
'/onlyrouteinside': typeof anotherGroupOnlyrouteinsideRoute
'/inside': typeof groupInsideRoute
Expand All @@ -350,6 +365,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 @@ -367,6 +383,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 All @@ -389,6 +406,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| ''
| '/anchor'
| '/posts'
| '/onlyrouteinside'
| '/inside'
Expand All @@ -405,6 +423,7 @@ export interface FileRouteTypes {
to:
| '/'
| ''
| '/anchor'
| '/onlyrouteinside'
| '/inside'
| '/lazyinside'
Expand All @@ -420,6 +439,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/_layout'
| '/anchor'
| '/posts'
| '/(another-group)/onlyrouteinside'
| '/(group)'
Expand All @@ -441,6 +461,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 @@ -451,6 +472,7 @@ export interface RootRouteChildren {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LayoutRoute: LayoutRouteWithChildren,
AnchorRoute: AnchorRoute,
PostsRoute: PostsRouteWithChildren,
anotherGroupOnlyrouteinsideRoute: anotherGroupOnlyrouteinsideRoute,
groupRoute: groupRouteWithChildren,
Expand All @@ -470,6 +492,7 @@ export const routeTree = rootRoute
"children": [
"/",
"/_layout",
"/anchor",
"/posts",
"/(another-group)/onlyrouteinside",
"/(group)",
Expand All @@ -486,6 +509,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
Loading