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 1 commit
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
hashChangeScrollIntoView?: boolean | ScrollIntoViewOptions
ignoreBlocker?: boolean
}
```
Expand Down
7 changes: 7 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
hashChangeScrollIntoView?: boolean | ScrollIntoViewOptions
ignoreBlocker?: boolean
},
) => Promise<void>
Expand All @@ -112,6 +113,12 @@ 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.
- `hashChangeScrollIntoView`
joshuaslate marked this conversation as resolved.
Show resolved Hide resolved
- 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.
- `ignoreBlocker`
- Type: `boolean`
- Optional
Expand Down
178 changes: 178 additions & 0 deletions examples/react/basic/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import ReactDOM from 'react-dom/client'
import {
ErrorComponent,
Expand All @@ -7,10 +8,13 @@ import {
createRootRoute,
createRoute,
createRouter,
useLocation,
useNavigate,
} from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { NotFoundError, fetchPost, fetchPosts } from './posts'
import type { ErrorComponentProps } from '@tanstack/react-router'
import * as React from 'react'

const rootRoute = createRootRoute({
component: RootComponent,
Expand Down Expand Up @@ -82,6 +86,179 @@ function IndexComponent() {
)
}

export const anchorRoute = createRoute({
joshuaslate marked this conversation as resolved.
Show resolved Hide resolved
getParentRoute: () => rootRoute,
path: 'anchor',
component: AnchorComponent,
})

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

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
hash={anchor.id}
activeOptions={{ includeHash: true }}
activeProps={{
className: 'font-bold',
}}
hashChangeScrollIntoView={anchor.hashChangeScrollIntoView}
>
{anchor.title}
</Link>
</li>
))}
</ul>
</nav>
<main className="overflow-auto">
<form
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 hashChangeScrollIntoView = withScroll
? ({
behavior: formData.get('scrollBehavior') ?? 'instant',
block: formData.get('scrollBlock') ?? 'start',
inline: formData.get('scrollInline') ?? 'nearest',
} as ScrollIntoViewOptions)
: false

navigate({ hash: toHash, hashChangeScrollIntoView })
}}
className="p-2 space-y-2"
>
<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"
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
type="checkbox"
checked={withScroll}
onChange={(e) => setWithScroll(e.target.checked)}
/>{' '}
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"
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"
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"
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">
Navigate
</button>
</div>
</form>

{anchors.map((anchor) => (
<div key={anchor.id} className="p-2 min-h-dvh">
<h1 id={anchor.id} className="font-bold text-xl pt-10">
{anchor.title}
</h1>
</div>
))}
</main>
</div>
)
}

export const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts',
Expand Down Expand Up @@ -203,6 +380,7 @@ const routeTree = rootRoute.addChildren([
layoutRoute.addChildren([
layout2Route.addChildren([layoutARoute, layoutBRoute]),
]),
anchorRoute,
indexRoute,
])

Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/RouterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
export interface CommitLocationOptions {
replace?: boolean
resetScroll?: boolean
hashChangeScrollIntoView?: boolean | ScrollIntoViewOptions
viewTransition?: boolean | ViewTransitionOptions
/**
* @deprecated All navigations use React transitions under the hood now
Expand Down
9 changes: 7 additions & 2 deletions packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,15 @@ export function Transitioner() {
}))

if (typeof document !== 'undefined' && (document as any).querySelector) {
if (router.state.location.hash !== '') {
if (
router.state.location.state.__hashChangeScrollIntoViewOptions &&
router.state.location.hash !== ''
) {
const el = document.getElementById(router.state.location.hash)
if (el) {
el.scrollIntoView()
el.scrollIntoView(
router.latestLocation.state.__hashChangeScrollIntoViewOptions,
)
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ declare module '@tanstack/history' {
interface HistoryState {
__tempLocation?: HistoryLocation
__tempKey?: string
__hashChangeScrollIntoViewOptions?: boolean | ScrollIntoViewOptions
}
}
6 changes: 6 additions & 0 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ export type NavigateOptions<
> = ToOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & NavigateOptionProps

export interface NavigateOptionProps {
// if set to `true`, the router will scroll the element with an id matching the hash into view with default ScrollIntoViewOptions.
// if set to `false`, the router will not scroll the element with an id matching the hash into view.
// if set to `ScrollIntoViewOptions`, the router will scroll the element with an id matching the hash into view with the provided options.
hashChangeScrollIntoView?: boolean | ScrollIntoViewOptions
// `replace` is a boolean that determines whether the navigation should replace the current history entry or push a new one.
replace?: boolean
resetScroll?: boolean
Expand Down Expand Up @@ -607,6 +611,7 @@ export function useLinkProps<
to,
preload: userPreload,
preloadDelay: userPreloadDelay,
hashChangeScrollIntoView,
replace,
startTransition,
resetScroll,
Expand Down Expand Up @@ -801,6 +806,7 @@ export function useLinkProps<
...options,
replace,
resetScroll,
hashChangeScrollIntoView,
startTransition,
viewTransition,
ignoreBlocker,
Expand Down
8 changes: 7 additions & 1 deletion packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ export class Router<
Math.random() * 10000000,
)}`
resetNextScroll = true
scrollOnNextHashChange = true
joshuaslate marked this conversation as resolved.
Show resolved Hide resolved
shouldViewTransition?: boolean | ViewTransitionOptions = undefined
isViewTransitionTypesSupported?: boolean = undefined
subscribers = new Set<RouterListener<RouterEvent>>()
Expand Down Expand Up @@ -1808,7 +1809,7 @@ export class Router<
this.load()
} else {
// eslint-disable-next-line prefer-const
let { maskedLocation, ...nextHistory } = next
let { maskedLocation, hashChangeScrollIntoView, ...nextHistory } = next

if (maskedLocation) {
nextHistory = {
Expand Down Expand Up @@ -1838,6 +1839,9 @@ export class Router<
}
}

nextHistory.state.__hashChangeScrollIntoViewOptions =
hashChangeScrollIntoView ?? true

this.shouldViewTransition = viewTransition

this.history[next.replace ? 'replace' : 'push'](
Expand All @@ -1859,6 +1863,7 @@ export class Router<
buildAndCommitLocation = ({
replace,
resetScroll,
hashChangeScrollIntoView,
viewTransition,
ignoreBlocker,
...rest
Expand All @@ -1881,6 +1886,7 @@ export class Router<
viewTransition,
replace,
resetScroll,
hashChangeScrollIntoView,
ignoreBlocker,
})
}
Expand Down