Skip to content

Commit

Permalink
docs(Portal): fix anchor hash highlighting (#2705)
Browse files Browse the repository at this point in the history
When clicking links on [this
page](https://eufemia.dnb.no/contribute/getting-started/), the target
heading should get an animation. Also when refreshing the page
afterwards, like so:

<img width="241" alt="Screenshot 2023-09-29 at 16 10 36"
src="https://github.com/dnbexperience/eufemia/assets/1501870/820ab31f-f77f-4cbc-a687-8db266bebc19">

---------

Co-authored-by: Anders <[email protected]>
  • Loading branch information
tujoworker and langz authored Sep 30, 2023
1 parent 4636276 commit 9c6299e
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,45 @@ import { Link, Anchor } from '@dnb/eufemia'
render(<Anchor href="/uilib/components/anchor">Accessible text</Anchor>)
```

### Combine a Link with an Anchor

You can combine a meta framework link, with the Anchor. This way, all the framework provided features will still work, as well as the behavior of the Eufemia Anchor.

```jsx
import Anchor from '@dnb/eufemia/components/Anchor'
import { Link } from 'gatsby'

render(
<App>
<Anchor element={Link} to="/path">
Link
</Anchor>
</App>,
)
```

### Anchor hash

Some browsers 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(
<App>
<Anchor href="/path#hash-id" onClick={scrollToHashHandler}>
Link
</Anchor>

<div id="hash-id">element to scroll to</div>
</App>,
)
```

## Blank target

**NB:** If you only use a vanilla HTML anchor element including `target="_blank"` then you have to ensure you add a `title` attribute that includes `Opens a new Window` or as a part of the text:

```html
Expand All @@ -24,7 +63,7 @@ render(<Anchor href="/uilib/components/anchor">Accessible text</Anchor>)
</a>
```

## Customize blank target graphic
### Customize blank target graphic

You may use a [tool like this url-encoder](https://yoksel.github.io/url-encoder/) to **url-encode** your SVG.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,3 @@ 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>
</>,
)
```
6 changes: 3 additions & 3 deletions packages/dnb-design-system-portal/src/e2e/pageLists.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ test.describe('Page Lists', () => {

await expect(
page.locator(
'#tabbar-content h2:has(a[href*="/uilib/components/"])',
'#tabbar-content h2:has(a[href*="/uilib/components/"]:not([aria-hidden]))',
),
).toHaveCount(listLength)
})
Expand Down Expand Up @@ -57,7 +57,7 @@ test.describe('Page Lists', () => {

await expect(
page.locator(
'#tabbar-content h2:has(a[href*="/uilib/extensions/"])',
'#tabbar-content h2:has(a[href*="/uilib/extensions/"]:not([aria-hidden]))',
),
).toHaveCount(listLength)
})
Expand Down Expand Up @@ -87,7 +87,7 @@ test.describe('Page Lists', () => {
.count()
await expect(
page.locator(
'#tabbar-content ul li:has(a[href*="/uilib/elements/"])',
'#tabbar-content ul li:has(a[href*="/uilib/elements/"]:not([aria-hidden]))',
),
).toHaveCount(listLength)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ test.describe('Page Navigation', () => {
test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false })

test.beforeEach(async ({ page, browser }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForURL('**/')
})
Expand Down
18 changes: 16 additions & 2 deletions packages/dnb-design-system-portal/src/e2e/pageScroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,26 @@ test.describe('Page Scroll', () => {
test('click on a table of content anchor should scroll the page to element', async ({
page,
}) => {
const element = await (
const anchorElement = (
await page.locator('main .dnb-ul li a').all()
).at(7)
await element?.click()
await anchorElement?.click()

const scrollY = await page.evaluate(() => window.scrollY)
expect(scrollY).toBeGreaterThanOrEqual(2000)
})

test('should highlight a linked hash element', async ({ page }) => {
const anchorElement = (
await page.locator('main .dnb-ul li a').all()
).at(7)
await anchorElement?.click()

expect(page.url()).toContain(
'/contribute/getting-started/#style-dependencies',
)

const headingElement = page.locator('.dnb-heading.focus')
await expect(headingElement).toHaveText('#Style dependencies')
})
})
13 changes: 11 additions & 2 deletions packages/dnb-design-system-portal/src/shared/tags/Anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default function Anchor({
if (isAbsoluteUrl) {
rest.target = '_blank'
rest.rel = 'noreferrer'
} else if (!/^\//.test(href)) {
} else if (!/^(\/|#)/.test(href)) {
href = `/${href}`
}

Expand All @@ -83,6 +83,15 @@ export default function Anchor({
if (onClick) {
onClick(event)
}
scrollToHashHandler(event)
try {
const element = scrollToHashHandler(event).element?.parentElement

if (element) {
element.classList.add('focus')
setTimeout(() => element.classList.remove('focus'), 3000)
}
} catch (error) {
console.error(error)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,55 +16,32 @@
opacity: 0;
}

@keyframes link-attention-focus {
0%,
100% {
visibility: visible;
color: var(--color-sea-green);
background-color: transparent;
}

35% {
color: var(--color-white);
background-color: var(--color-sea-green);
}

// stylelint-disable-next-line
0%,
80% {
opacity: 1;
}

// stylelint-disable-next-line
100% {
opacity: 0;
}
}

.anchor-hash.focus {
animation: link-attention-focus 2.2s ease-in-out 1 10ms;
}
animation: link-attention-focus 2.2s var(--easing-default) 1 10ms;

.focus {
display: inline-block;

@keyframes parent-attention-focus {
@keyframes link-attention-focus {
0%,
100% {
color: currentcolor;
visibility: visible;
color: var(--color-sea-green);
background-color: transparent;
}

35% {
color: var(--color-white);
background-color: var(--color-sea-green);
}
}

animation: parent-attention-focus 2.2s ease-in-out 1 10ms;
// stylelint-disable-next-line
0%,
80% {
opacity: 1;
}

* {
animation: parent-attention-focus 3s ease-in-out 1 150ms;
// stylelint-disable-next-line
100% {
opacity: 0;
}
}
}
}
Expand All @@ -74,4 +51,27 @@
visibility: visible;
opacity: 1;
}

&:global(.focus) {
display: inline-block;

@keyframes parent-attention-focus {
0%,
100% {
color: initial;
background-color: initial;
}

35% {
color: var(--color-white);
background-color: var(--color-sea-green);
}
}

animation: parent-attention-focus 2.2s var(--easing-default) 1 10ms;

* {
animation: parent-attention-focus 3s var(--easing-default) 1 150ms;
}
}
}
25 changes: 15 additions & 10 deletions packages/dnb-eufemia/src/components/anchor/Anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ const Anchor = React.forwardRef(
export default Anchor

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

if (typeof document === 'undefined' || !href.includes('#')) {
Expand All @@ -171,14 +171,19 @@ export function scrollToHashHandler(
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 })
try {
const scrollPadding = parseFloat(
window.getComputedStyle(document.documentElement)
.scrollPaddingTop
)
const top = getOffsetTop(anchorElem) - scrollPadding || 0

window.scroll({ top })

return { element: anchorElem }
} catch (error) {
console.error(error)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,74 @@ describe('Anchor with scrollToHashHandler', () => {
expect(onScroll).toHaveBeenCalledWith({ top: 0 })
})

it('should not call preventDefault', () => {
const preventDefault = jest.fn()
const onScroll = jest.fn()

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

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

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

expect(preventDefault).toHaveBeenCalledTimes(0)
expect(onScroll).toHaveBeenCalledTimes(1)
})

it('should return { element } on match', () => {
let returnResult = null

const onScroll = jest.fn()
const onClick = jest.fn((event) => {
returnResult = scrollToHashHandler(event)
})

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

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

const spanElement = document.querySelector('span')
const anchorElement = document.querySelector('a')
fireEvent.click(anchorElement)

expect(onClick).toHaveBeenCalledTimes(1)
expect(onClick).toHaveBeenCalledWith(
expect.objectContaining({ target: anchorElement })
)
expect(onScroll).toHaveBeenCalledTimes(1)
expect(returnResult).toEqual(
expect.objectContaining({ element: spanElement })
)
})

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

Expand Down

0 comments on commit 9c6299e

Please sign in to comment.