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(Anchor): add scrollToHash feature #2290

Merged
merged 2 commits into from
May 11, 2023
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
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)
})
})