diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/anchor/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/anchor/properties.mdx index c38e4e599e1..18585c0ebd5 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/anchor/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/anchor/properties.mdx @@ -28,3 +28,23 @@ render( ) ``` + +### 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( + <> + + {children} + + +
element to scroll to
+ +) +``` diff --git a/packages/dnb-design-system-portal/src/shared/tags/Anchor.tsx b/packages/dnb-design-system-portal/src/shared/tags/Anchor.tsx index a237c3988eb..9fd4188267f 100644 --- a/packages/dnb-design-system-portal/src/shared/tags/Anchor.tsx +++ b/packages/dnb-design-system-portal/src/shared/tags/Anchor.tsx @@ -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] === '!') { @@ -17,34 +17,10 @@ const Anchor = ({ children, href, ...rest }) => { } return ( - + {children} ) - - 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 diff --git a/packages/dnb-eufemia/src/components/anchor/Anchor.tsx b/packages/dnb-eufemia/src/components/anchor/Anchor.tsx index a20ad0b60ec..5a516493957 100644 --- a/packages/dnb-eufemia/src/components/anchor/Anchor.tsx +++ b/packages/dnb-eufemia/src/components/anchor/Anchor.tsx @@ -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' @@ -120,3 +121,43 @@ const Anchor = React.forwardRef( ) export default Anchor + +export function scrollToHashHandler( + e: React.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 }) + } + } +} diff --git a/packages/dnb-eufemia/src/components/anchor/__tests__/AnchorScroll.test.tsx b/packages/dnb-eufemia/src/components/anchor/__tests__/AnchorScroll.test.tsx new file mode 100644 index 00000000000..5216a2c18ed --- /dev/null +++ b/packages/dnb-eufemia/src/components/anchor/__tests__/AnchorScroll.test.tsx @@ -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( + <> + + text + + + + ) + + 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( + <> + + text + + + + ) + + 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( + <> + + text + + + + ) + + 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( + + text + + ) + + 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( + <> + + text + + + + ) + + const element = document.querySelector('a') + fireEvent.click(element) + + expect(onScoll).toHaveBeenCalledTimes(0) + }) +})