Skip to content

Commit

Permalink
feat(useHeightAnimation): add hook to make height (auto) animations e…
Browse files Browse the repository at this point in the history
…asy (for internal use as of now)
  • Loading branch information
tujoworker committed Sep 14, 2022
1 parent 56829ef commit 2ec0c24
Show file tree
Hide file tree
Showing 5 changed files with 371 additions and 18 deletions.
2 changes: 2 additions & 0 deletions packages/dnb-design-system-portal/src/docs/uilib/helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ tabs:
key: /uilib/helpers/classes
- title: Functions
key: /uilib/helpers/functions
- title: Hooks
key: /uilib/helpers/hooks
redirect_from:
- /uilib/helper-classes
---
Expand Down
121 changes: 103 additions & 18 deletions packages/dnb-design-system-portal/src/docs/uilib/helpers/Examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,114 @@

import React from 'react'
import styled from '@emotion/styled'
import classnames from 'classnames'
import ComponentBox from 'dnb-design-system-portal/src/shared/tags/ComponentBox'
import { useHeightAnimation } from '@dnb/eufemia/src/shared/useHeightAnimation'

// have a limit because this page is used for screenshot tests
const Wrapper = styled.div`
max-width: 40rem;
`

export function HeightAnimationExample() {
return (
<ComponentBox useRender scope={{ useHeightAnimation, classnames }}>
{
/* jsx */ `
const AnimatedContent = ({
open = false,
noAnimation = false,
...rest
}) => {
const animationElement = React.useRef()
const { isOpen, isInDOM, isInTransition } = useHeightAnimation(
animationElement,
{
open,
animate: !noAnimation,
}
)
// You can also entirely move it from the DOM
// if (!isInDOM) {
// return null
// }
return (
<AnimatedDiv
className={classnames(
'wrapper-element',
// Optional: will toggle immediately
isOpen && 'is-open',
// Optional: is "true" while the element "should" be in the DOM (during animation)
isInDOM && 'is-in-dom',
// Optional: is "true" when completely opened, and "false" right after closing has started (usefull for additional CSS transitions)
isInTransition && 'is-in-transition'
)}
style_type="lavender"
{...rest}
>
{isInDOM /* <-- Optional */ && (
<div ref={animationElement} className="animation-element">
<P className="content-element" space={0}>Your content</P>
</div>
)}
</AnimatedDiv>
)
}
const HeightAnimation = ({ open = false, ...rest }) => {
const [openState, setOpenState] = React.useState(open)
const onChangeHandler = ({ checked }) => {
setOpenState(checked)
}
return (
<>
<ToggleButton checked={openState} onChange={onChangeHandler}>
Toggle me
</ToggleButton>
<AnimatedContent top open={openState} />
</>
)
}
const AnimatedDiv = styled(Section)\`
.animation-element {
overflow: hidden;
transition: height 1s var(--easing-default);
}
.content-element {
transition: transform 1s var(--easing-default);
transform: translateY(-2rem);
}
&.is-in-transition .content-element {
transform: translateY(0);
}
.content-element {
padding: 4rem 0;
}
\`
render(<HeightAnimation />)
`
}
</ComponentBox>
)
}

export function CoreStyleExample() {
return (
<Wrapper className="dnb-spacing">
<ComponentBox
reactLive
hideCode
data-visual-test="helper-core-style"
>
<ComponentBox hideCode data-visual-test="helper-core-style">
{
/* jsx */ `
<div className="dnb-core-style">
Expand All @@ -39,7 +132,7 @@ export function CoreStyleExample() {
export function TabFocusExample() {
return (
<Wrapper className="dnb-spacing">
<ComponentBox reactLive hideCode data-visual-test="helper-tap-focus">
<ComponentBox hideCode data-visual-test="helper-tap-focus">
{
/* jsx */ `
<details>
Expand All @@ -59,11 +152,7 @@ export function TabFocusExample() {
export function UnstyledListExample() {
return (
<Wrapper className="dnb-spacing">
<ComponentBox
reactLive
hideCode
data-visual-test="helper-unstyled-list"
>
<ComponentBox hideCode data-visual-test="helper-unstyled-list">
{
/* jsx */ `
<ul className="dnb-unstyled-list">
Expand All @@ -84,7 +173,7 @@ export function UnstyledListExample() {
export function ScreenReaderOnlyExample() {
return (
<Wrapper className="dnb-spacing">
<ComponentBox reactLive hideCode data-visual-test="helper-sr-only">
<ComponentBox hideCode data-visual-test="helper-sr-only">
{
/* jsx */ `
<p className="dnb-p">
Expand All @@ -104,11 +193,7 @@ export function ScreenReaderOnlyExample() {
export function NoScreenReaderExample() {
return (
<Wrapper className="dnb-spacing">
<ComponentBox
reactLive
hideCode
data-visual-test="helper-not-sr-only"
>
<ComponentBox hideCode data-visual-test="helper-not-sr-only">
{
/* jsx */ `
<p className="dnb-p dnb-sr-only dnb-not-sr-only">
Expand All @@ -125,7 +210,7 @@ export function NoScreenReaderExample() {
export function SelectionExample() {
return (
<Wrapper className="dnb-spacing">
<ComponentBox reactLive hideCode data-visual-test="helper-selection">
<ComponentBox hideCode data-visual-test="helper-selection">
{
/* jsx */ `
<p className="dnb-selection dnb-p__size--basis">
Expand Down
29 changes: 29 additions & 0 deletions packages/dnb-design-system-portal/src/docs/uilib/helpers/hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
showTabs: true
---

import {
HeightAnimationExample,
} from 'Docs/uilib/helpers/Examples'
import SkipLinkExample from 'Docs/uilib/usage/accessibility/examples/skip-link-example.js'

## Description

These React Hooks are internally used in the components, and are with that a good choice when it comes to save bandwidth in the final production bundle.

## `useHeightAnimation`

In many places we want to animate the content in and out. The challenge is to never define a fixed height, because of a unknown content size and a different font size the users is using.

The `useHeightAnimation` hook takes an HTML Element, and animates it from 0 to the current content. When the animation is done, it tests the element height to `auto`.

The element animation is done with a CSS transition, e.g.:

```css
.animation-element {
overflow: hidden;
transition: height 1s var(--easing-default);
}
```

<HeightAnimationExample />
150 changes: 150 additions & 0 deletions packages/dnb-eufemia/src/shared/__tests__/useHeightAnimation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* useHeightAnimation Tests
*
*/

import React from 'react'
import classnames from 'classnames'
import { render, act, fireEvent } from '@testing-library/react'
import { useHeightAnimation } from '../useHeightAnimation'
import ToggleButton from '../../components/ToggleButton'
import { wait } from '@testing-library/user-event/dist/utils'

beforeEach(() => {
window.requestAnimationFrame = jest.fn((callback) => {
return setTimeout(callback, 0)
})
window.cancelAnimationFrame = jest.fn((id) => {
clearTimeout(id)
return id
})
})

describe('useHeightAnimation', () => {
const AnimatedContent = ({ open = false, noAnimation = false }) => {
const animationElement = React.useRef()
const { isOpen, isInDOM, isInTransition } = useHeightAnimation(
animationElement,
{
open,
animate: !noAnimation,
}
)

return (
<div
className={classnames(
'wrapper-element',
isOpen && 'is-open',
isInDOM && 'is-in-dom',
isInTransition && 'is-in-transition'
)}
>
<div ref={animationElement} className="animation-element">
<div className="content">content</div>
</div>
</div>
)
}

const Component = ({ open = false }) => {
const [openState, setOpenState] = React.useState(open)

const onChangeHandler = ({ checked }) => {
setOpenState(checked)
}

return (
<>
<ToggleButton checked={openState} onChange={onChangeHandler}>
Toggle me
</ToggleButton>

<AnimatedContent open={open || openState} />

<p>Text</p>
</>
)
}

it('should be closed by default', () => {
render(<Component />)
expect(document.querySelector('.is-in-dom')).toBeFalsy()
})

it('should have element in DOM when open property is true', () => {
const { rerender } = render(<Component />)

expect(document.querySelector('.is-in-dom')).toBeFalsy()

rerender(<Component open />)

expect(document.querySelector('.is-in-dom')).toBeTruthy()
})

it('should set height style to auto', async () => {
const { rerender } = render(<Component />)

rerender(<Component open />)

await act(async () => {
const element = document.querySelector('.animation-element')

expect(element.getAttribute('style')).toBe('')

await wait(1)

expect(element.getAttribute('style')).toBe('height: 0px;')

simulateAnimationEnd(element)

expect(element.getAttribute('style')).toBe('height: auto;')
})
})

it('should act with different states through the animation transition', async () => {
render(<Component />)

await act(async () => {
fireEvent.click(document.querySelector('button'))

await wait(1)

expect(
Array.from(document.querySelector('.wrapper-element').classList)
).toEqual(['wrapper-element', 'is-in-dom'])

await wait(1)

expect(
Array.from(document.querySelector('.wrapper-element').classList)
).toEqual([
'wrapper-element',
'is-open',
'is-in-dom',
'is-in-transition',
])

fireEvent.click(document.querySelector('button'))

await wait(1)

expect(
Array.from(document.querySelector('.wrapper-element').classList)
).toEqual(['wrapper-element', 'is-open', 'is-in-dom'])

simulateAnimationEnd(document.querySelector('.animation-element'))

await wait(1)

expect(
Array.from(document.querySelector('.wrapper-element').classList)
).toEqual(['wrapper-element'])
})
})
})

function simulateAnimationEnd(element: Element) {
const event = new CustomEvent('transitionend')
element.dispatchEvent(event)
}
Loading

0 comments on commit 2ec0c24

Please sign in to comment.