Skip to content

Commit

Permalink
feat(Anchor): add scrollToHash feature (#2290)
Browse files Browse the repository at this point in the history
* feat(Anchor): add `scrollToHash` feature

Closes #2286

* Make the import optional, so its not imported when tree-shaked
  • Loading branch information
tujoworker committed May 31, 2023
1 parent 8414ae7 commit 3136367
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,23 @@ render(
</Anchor>
)
```

### Anchor hash

Some browser like Chrome (behind a flag) does still not support animated anchor hash clicks when CSS `scroll-behavior: smooth;` is set. To make it work, you can provide the `scrollToHashHandler` helper function to the Anchor:

```jsx
import Anchor, {
scrollToHashHandler,
} from '@dnb/eufemia/components/Anchor'

render(
<>
<Anchor href="/path#hash-id" onClick={scrollToHashHandler}>
{children}
</Anchor>

<div id="hash-id">element to scroll to</div>
</>
)
```
30 changes: 3 additions & 27 deletions packages/dnb-design-system-portal/src/shared/tags/Anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
*
*/

import React, { MouseEvent } from 'react'
import React from 'react'
import { Link } from '@dnb/eufemia/src'
import { getOffsetTop } from '@dnb/eufemia/src/shared/helpers'
import { scrollToHashHandler } from '@dnb/eufemia/src/components/Anchor'

const Anchor = ({ children, href, ...rest }) => {
if (/^http/.test(href) || href[0] === '!') {
Expand All @@ -17,34 +17,10 @@ const Anchor = ({ children, href, ...rest }) => {
}

return (
<Link lang="en-GB" href={href} {...rest} onClick={clickHandler}>
<Link lang="en-GB" href={href} {...rest} onClick={scrollToHashHandler}>
{children}
</Link>
)

function clickHandler(e: MouseEvent) {
/**
* What happens here?
* When `scroll-behavior: smooth;` in CSS is set,
* Blink/Chromium wants the user to click two times in order to actually scroll to the anchor hash.
* The first click, sets the hash, the second one, srollts to it.
* We want Chromium browsers to scorll to the element on the first click.
*/
const id = e.currentTarget
.getAttribute('href')
.split(/#/g)
.reverse()[0]
const anchorElem = document.getElementById(id)
if (anchorElem instanceof HTMLElement) {
e.preventDefault()

const scrollPadding = parseFloat(
window.getComputedStyle(document.documentElement).scrollPaddingTop
)
const top = getOffsetTop(anchorElem) - scrollPadding
window.scroll({ top })
}
}
}

export default Anchor
41 changes: 41 additions & 0 deletions packages/dnb-eufemia/src/components/anchor/Anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
makeUniqueId,
extendPropsWithContext,
} from '../../shared/component-helper'
import { getOffsetTop } from '../../shared/helpers'
import Tooltip from '../tooltip/Tooltip'
import type { SkeletonShow } from '../skeleton/Skeleton'
import type { SpacingProps } from '../../shared/types'
Expand Down Expand Up @@ -120,3 +121,43 @@ const Anchor = React.forwardRef(
)

export default Anchor

export function scrollToHashHandler(
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
const element = e.currentTarget as HTMLAnchorElement
const href = element.getAttribute('href')

if (typeof document === 'undefined' || !href.includes('#')) {
return // stop here
}

/**
* What happens here?
* When `scroll-behavior: smooth;` in CSS is set,
* Blink/Chromium wants the user to click two times in order to actually scroll to the anchor hash.
* The first click, sets the hash, the second one, srollts to it.
* We want Chromium browsers to scorll to the element on the first click.
*/
const isSamePath =
href.startsWith('#') ||
window.location.href.includes(element.pathname?.replace(/\/$/, ''))

// Only continue, when we are sure we are on the same page,
// because, the same ID may exists occasionally on the current page.
if (isSamePath) {
const id = href.split(/#/g).reverse()[0]
const anchorElem = document.getElementById(id)

if (anchorElem instanceof HTMLElement) {
e.preventDefault()

const scrollPadding = parseFloat(
window.getComputedStyle(document.documentElement).scrollPaddingTop
)
const top = getOffsetTop(anchorElem) - scrollPadding || 0

window.scroll({ top })
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Element Test
*
*/

import React from 'react'
import { fireEvent, render } from '@testing-library/react'
import Anchor, { scrollToHashHandler } from '../Anchor'

describe('Anchor with scrollToHashHandler', () => {
let location: Location

beforeEach(() => {
location = window.location
})

it('should call window.scroll', () => {
const onScoll = jest.fn()

jest.spyOn(window, 'scroll').mockImplementationOnce(onScoll)
jest.spyOn(window, 'location', 'get').mockReturnValueOnce({
...location,
href: 'http://localhost/path',
})

render(
<>
<Anchor onClick={scrollToHashHandler} href="/path/#hash-id">
text
</Anchor>
<span id="hash-id" />
</>
)

const element = document.querySelector('a')
fireEvent.click(element)

expect(onScoll).toHaveBeenCalledTimes(1)
expect(onScoll).toHaveBeenCalledWith({ top: 0 })
})

it('should use last hash', () => {
const onScoll = jest.fn()

jest.spyOn(window, 'scroll').mockImplementationOnce(onScoll)
jest.spyOn(window, 'location', 'get').mockReturnValueOnce({
...location,
href: 'http://localhost/path',
})

render(
<>
<Anchor
onClick={scrollToHashHandler}
href="/path/#first-hash#hash-id"
>
text
</Anchor>
<span id="hash-id" />
</>
)

const element = document.querySelector('a')
fireEvent.click(element)

expect(onScoll).toHaveBeenCalledTimes(1)
expect(onScoll).toHaveBeenCalledWith({ top: 0 })
})

it('should not call window.scroll when no hash-id found', () => {
const onScoll = jest.fn()

jest.spyOn(window, 'scroll').mockImplementationOnce(onScoll)
jest.spyOn(window, 'location', 'get').mockReturnValueOnce({
...location,
href: 'http://localhost/path',
})

render(
<>
<Anchor onClick={scrollToHashHandler} href="/path/#hash-id">
text
</Anchor>
<span id="other-id" />
</>
)

const element = document.querySelector('a')
fireEvent.click(element)

expect(onScoll).toHaveBeenCalledTimes(0)
})

it('will skip when no # exists in href', () => {
const onScoll = jest.fn()

jest.spyOn(window, 'scroll').mockImplementationOnce(onScoll)
jest.spyOn(window, 'location', 'get').mockReturnValueOnce({
...location,
href: 'http://localhost/path',
})

render(
<Anchor onClick={scrollToHashHandler} href="/path">
text
</Anchor>
)

const element = document.querySelector('a')
fireEvent.click(element)

expect(onScoll).toHaveBeenCalledTimes(0)
})

it('should not call window.scroll when not on same page', () => {
const onScoll = jest.fn()

jest.spyOn(window, 'scroll').mockImplementationOnce(onScoll)
jest.spyOn(window, 'location', 'get').mockReturnValueOnce({
...location,
href: 'http://localhost/path',
})

render(
<>
<Anchor onClick={scrollToHashHandler} href="/other-path/#hash-id">
text
</Anchor>
<span id="hash-id" />
</>
)

const element = document.querySelector('a')
fireEvent.click(element)

expect(onScoll).toHaveBeenCalledTimes(0)
})
})

0 comments on commit 3136367

Please sign in to comment.