From a6373edaf3a740450851818b0cf98c77f6401048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Fri, 5 May 2023 14:42:42 +0200 Subject: [PATCH] feat(Anchor): add `scrollToHash` feature Closes #2286 --- .../uilib/components/anchor/properties.mdx | 1 + .../src/shared/tags/Anchor.tsx | 29 +--- .../src/components/anchor/Anchor.tsx | 60 ++++++++ .../anchor/__tests__/AnchorScroll.test.tsx | 136 ++++++++++++++++++ 4 files changed, 199 insertions(+), 27 deletions(-) create mode 100644 packages/dnb-eufemia/src/components/anchor/__tests__/AnchorScroll.test.tsx 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..79fd89db07e 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 @@ -11,6 +11,7 @@ showTabs: true | `to` | _(optional)_ use this prop only if you are using a router Link component as the `element` that uses the `to` property to declare the navigation url. | | `target` | _(optional)_ defines the opening method. Use `_blank` to open a new browser window/tab. | | `targetBlankTitle` | _(optional)_ the title shown as a tooltip when target is set to `_blank`. | +| `scrollToHash` | _(optional)_ When set to true, a click will attempt to use `window.scroll` to support the missing Chromium support. | | `tooltip` | _(optional)_ Provide a string or a React Element to be shown as the tooltip content. | | `skeleton` | _(optional)_ if set to `true`, an overlaying skeleton with animation will be shown. | | [Space](/uilib/components/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. | 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..9d8a4c4bc4e 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,8 @@ * */ -import React, { MouseEvent } from 'react' +import React from 'react' import { Link } from '@dnb/eufemia/src' -import { getOffsetTop } from '@dnb/eufemia/src/shared/helpers' const Anchor = ({ children, href, ...rest }) => { if (/^http/.test(href) || href[0] === '!') { @@ -17,34 +16,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..9f46aeed8ca 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' @@ -26,6 +27,13 @@ export type AnchorProps = { omitClass?: boolean innerRef?: React.RefObject + /** + * When set to true, + * and there is a hash with an existing and matching id, + * it uses window.scroll to support the missing scroll feature in Chromium browsers. + */ + scrollToHash?: boolean + /** @deprecated use innerRef instead */ inner_ref?: React.RefObject } @@ -65,6 +73,7 @@ export function AnchorInstance(localProps: AnchorAllProps) { omitClass, innerRef, targetBlankTitle, + scrollToHash, ...rest } = allProps @@ -94,6 +103,13 @@ export function AnchorInstance(localProps: AnchorAllProps) { 'dnb-anchor--no-icon' )} {...attributes} + onClick={(e) => { + attributes?.['onClick']?.(e) + + if (scrollToHash) { + scrollToHashHandler(e) + } + }} innerRef={innerRef} > {children} @@ -111,6 +127,50 @@ export function AnchorInstance(localProps: AnchorAllProps) { )} ) + + function scrollToHashHandler( + e: React.MouseEvent + ) { + const element = e.currentTarget as HTMLAnchorElement + const href = element.getAttribute('href') + if (!href.includes('#') || typeof document === 'undefined') { + 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 samePath = + href.startsWith('#') || + window.location.href.includes(removeEndingSlash(element.pathname)) + + // Only continue, when we are sure we are on the same page, + // because, the same ID may exists occasionally on the current page. + if (samePath) { + 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 }) + } + } + } +} + +const removeEndingSlash = (url: string) => { + return url.replace(/\/$/, '') } const Anchor = React.forwardRef( 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..00a806261c7 --- /dev/null +++ b/packages/dnb-eufemia/src/components/anchor/__tests__/AnchorScroll.test.tsx @@ -0,0 +1,136 @@ +/** + * Element Test + * + */ + +import React from 'react' +import { fireEvent, render } from '@testing-library/react' +import Anchor from '../Anchor' + +describe('Anchor with scrollToHash', () => { + let location: Location + + beforeEach(() => { + location = window.location + // jest.spyOn(window, 'location', 'get').mockRestore() + }) + + 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) + }) +})