From f0779c294313d8f26f0af637135004a2b15de27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Fri, 23 Sep 2022 09:21:26 +0200 Subject: [PATCH] feat(HeightAnimation): adjust height with animation when content changes (#1569) --- .../components/height-animation/Examples.tsx | 68 ++++++++-- .../components/height-animation/events.md | 5 +- .../uilib/components/height-animation/info.md | 12 +- .../breadcrumb/stories/Breadcrumb.stories.tsx | 26 ++-- .../height-animation/HeightAnimation.tsx | 26 +++- .../__tests__/HeightAnimation.test.tsx | 49 ++++++- .../stories/HeightAnimation.stories.tsx | 53 ++++++-- .../style/_height-animation.scss | 5 +- .../height-animation/useHeightAnimation.tsx | 120 ++++++++++++++++-- .../dnb-eufemia/src/shared/AnimateHeight.ts | 2 +- 10 files changed, 309 insertions(+), 57 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/Examples.tsx index 1a3141380fd..683940628fa 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/Examples.tsx @@ -12,6 +12,8 @@ export function HeightAnimationDefault() { /* jsx */ ` const Example = () => { const [openState, setOpenState] = React.useState(false) + const [isOpen, setIsOpen] = React.useState(openState) + const [contentState, setContentState] = React.useState(false) const onChangeHandler = ({ checked }) => { setOpenState(checked) @@ -19,19 +21,39 @@ const Example = () => { return ( <> - - Toggle me + { + setOpenState(checked) + }} + right + > + Open/close + + { + setContentState(checked) + }} + space={{ top: true, bottom: true }} + > + Change height inside -
+
-

- Your content -

+
+

Your content

+
+ {contentState &&

More content

}
+ +

Look at me 👀

) } @@ -49,7 +71,9 @@ export function HeightAnimationKeepInDOM() { { /* jsx */ ` const Example = () => { - const [openState, setOpenState] = React.useState(false) + const [openState, setOpenState] = React.useState(true) + const [isOpen, setIsOpen] = React.useState(openState) + const [contentState, setContentState] = React.useState(false) const onChangeHandler = ({ checked }) => { setOpenState(checked) @@ -57,19 +81,37 @@ const Example = () => { return ( <> - - Toggle me + { + setOpenState(checked) + }} + right + > + Open/close + + { + setContentState(checked) + }} + space={{ top: true, bottom: true }} + > + Change height inside - + -

- Your content -

+
+

Your content

+
+ {contentState &&

More content

}
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/events.md b/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/events.md index 875df1bbea9..21cdb369a28 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/events.md +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/events.md @@ -4,4 +4,7 @@ showTabs: true ## Events -No events are supported at the moment. +| Events | Description | +| -------- | ----------------------------------------------------------------------------------------------------- | +| `onOpen` | _(optional)_ Is called when fully opened or closed. Returns `true` or `false` depending on the state. | +| `onOpen` | _(optional)_ Is called when fully opened or closed. Returns `true` or `false` depending on the state. | diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/info.md b/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/info.md index f4393e22c11..30fd4f1ea5a 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/info.md +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/height-animation/info.md @@ -4,14 +4,20 @@ showTabs: true ## Description -The HeightAnimation component is a helper component to animate from 0px to height:auto powered by CSS. It calculates the height on the fly. +The HeightAnimation component is a helper component to animate from `0` to `height: auto` powered by CSS. It calculates the height on the fly. When the animation is done, it sets the element's height to `auto`. The component can be used as an opt-int replacement instead of vanilla HTML Elements. -The element animation is done with a CSS transition and a `400ms` duration: +The element animation is done with a CSS transition with `400ms` in duration. + +It also re-calculates and changes the height, when the given content changes. ## Accessibility -It is important to never animate from 0 to e.g. 64px – because the content may differ based on the viewport width (screen size), the content itself, or the user may even have a larger `font-size`. +It is important to never animate from 0 to e.g. 64px – because; + +- the content may differ based on the viewport width (screen size) +- the content itself may change +- the user may have a larger `font-size` diff --git a/packages/dnb-eufemia/src/components/breadcrumb/stories/Breadcrumb.stories.tsx b/packages/dnb-eufemia/src/components/breadcrumb/stories/Breadcrumb.stories.tsx index c661536ce6e..37b2d32fd22 100644 --- a/packages/dnb-eufemia/src/components/breadcrumb/stories/Breadcrumb.stories.tsx +++ b/packages/dnb-eufemia/src/components/breadcrumb/stories/Breadcrumb.stories.tsx @@ -9,6 +9,7 @@ import React, { useState } from 'react' import { Box, Wrapper } from 'storybook-utils/helpers' import { Skeleton, + ToggleButton, // ToggleButton, // Button, } from '../../' @@ -44,19 +45,24 @@ const breadcrumbItems: BreadcrumbItemProps[] = [ ] export const Multiple = () => { - // const [showHome, makeHomeVisible] = React.useState(false) + const list = [...breadcrumbItems] + const [removeLast, setRemoveLast] = React.useState(false) + if (removeLast) { + list.pop() + } return ( - {/* { - makeHomeVisible((s) => !s) - }} - > - Toggle Home - -
*/} - + { + setRemoveLast((s) => !s) + }} + > + Toggle last item + + +
) diff --git a/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx b/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx index 797ccf58978..f244ab52907 100644 --- a/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx +++ b/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx @@ -41,6 +41,18 @@ export type HeightAnimationProps = { */ innerRef?: React.RefObject + /** + * Is called when fully opened or closed + * Default: null + */ + onOpen?: (isOpen: boolean) => void + + /** + * Is called when animation is done and the full height has reached + * Default: null + */ + onAnimationEnd?: () => void + className?: React.ReactNode children?: React.ReactNode | HTMLElement } @@ -54,17 +66,20 @@ export default function HeightAnimation({ className, innerRef, children, + onOpen = null, + onAnimationEnd = null, ...props }: HeightAnimationProps & ISpacingProps) { const ref = React.useRef() - const { isInDOM, isVisible, isVisibleParallax } = useHeightAnimation( - innerRef || ref, - { + const { isInDOM, isVisible, isVisibleParallax, isAnimating } = + useHeightAnimation(innerRef || ref, { open, animate, - } - ) + children, + onOpen, + onAnimationEnd, + }) if (!isInDOM && !keepInDOM) { return null @@ -84,6 +99,7 @@ export default function HeightAnimation({ isInDOM && 'dnb-height-animation--is-in-dom', isVisible && 'dnb-height-animation--is-visible', isVisibleParallax && 'dnb-height-animation--parallax', + isAnimating && 'dnb-height-animation--animating', className )} style={style} diff --git a/packages/dnb-eufemia/src/components/height-animation/__tests__/HeightAnimation.test.tsx b/packages/dnb-eufemia/src/components/height-animation/__tests__/HeightAnimation.test.tsx index 7f100da9184..322d90c8b3f 100644 --- a/packages/dnb-eufemia/src/components/height-animation/__tests__/HeightAnimation.test.tsx +++ b/packages/dnb-eufemia/src/components/height-animation/__tests__/HeightAnimation.test.tsx @@ -33,6 +33,7 @@ describe('HeightAnimation', () => { open = false, animate = true, element = 'div', + children, ...props }: Partial) => { const [openState, setOpenState] = React.useState(open) @@ -58,7 +59,7 @@ describe('HeightAnimation', () => { animate={animate} // Optional {...props} > -

Your content

+

Your content {children}

@@ -115,6 +116,50 @@ describe('HeightAnimation', () => { }) }) + it('should adjust height when content changes', async () => { + const { rerender } = render() + + expect(document.querySelector('.dnb-height-animation')).toBeFalsy() + + rerender() + + await act(async () => { + const element = document.querySelector('.dnb-height-animation') + + simulateAnimationEnd() + + expect( + document + .querySelector('.dnb-height-animation') + .getAttribute('style') + ).toBe('height: auto;') + + rerender(123) + + await wait(1) + + expect( + document + .querySelector('.dnb-height-animation') + .getAttribute('style') + ).toBe('height: 0px;') + + jest + .spyOn(element, 'clientHeight', 'get') + .mockImplementationOnce(() => 100) + + rerender(456) + + await wait(1) + + expect( + document + .querySelector('.dnb-height-animation') + .getAttribute('style') + ).toBe('height: 100px;') + }) + }) + it('should have content in DOM when keepInDOM is true', async () => { const { rerender } = render() @@ -178,6 +223,7 @@ describe('HeightAnimation', () => { 'dnb-height-animation--is-in-dom', 'dnb-height-animation--is-visible', 'dnb-height-animation--parallax', + 'dnb-height-animation--animating', ]) fireEvent.click(document.querySelector('button')) @@ -187,6 +233,7 @@ describe('HeightAnimation', () => { 'dnb-height-animation', 'dnb-height-animation--is-in-dom', 'dnb-height-animation--is-visible', + 'dnb-height-animation--animating', ]) simulateAnimationEnd() diff --git a/packages/dnb-eufemia/src/components/height-animation/stories/HeightAnimation.stories.tsx b/packages/dnb-eufemia/src/components/height-animation/stories/HeightAnimation.stories.tsx index d02e8396c44..cde633473a3 100644 --- a/packages/dnb-eufemia/src/components/height-animation/stories/HeightAnimation.stories.tsx +++ b/packages/dnb-eufemia/src/components/height-animation/stories/HeightAnimation.stories.tsx @@ -7,7 +7,7 @@ import styled from '@emotion/styled' import React from 'react' import { P } from '../../../elements' import Section from '../../section/Section' -import ToggleButton from '../../toggle-button/ToggleButton' +import { ToggleButton, Button } from '../../' import HeightAnimation from '../HeightAnimation' export default { @@ -15,30 +15,59 @@ export default { } export const HeightAnimationSandbox = () => { - const [openState, setOpenState] = React.useState(false) - - const onChangeHandler = ({ checked }) => { - setOpenState(checked) - } + const [count, setCount] = React.useState(0) + const [openState, setOpenState] = React.useState(true) + const [isOpen, setIsOpen] = React.useState(true) + const [contentState, setContentState] = React.useState(false) return ( <> - - Toggle me + { + setOpenState(checked) + }} + right + > + Open/close + + + { + setContentState(checked) + }} + right + > + Change height inside - + + + -

- Your content -

+
+

Your content

+
+ {contentState &&

More content

}
+ +

Look at me 👀

) } diff --git a/packages/dnb-eufemia/src/components/height-animation/style/_height-animation.scss b/packages/dnb-eufemia/src/components/height-animation/style/_height-animation.scss index ca586c90db5..380e15a604c 100644 --- a/packages/dnb-eufemia/src/components/height-animation/style/_height-animation.scss +++ b/packages/dnb-eufemia/src/components/height-animation/style/_height-animation.scss @@ -4,6 +4,9 @@ */ .dnb-height-animation { - overflow: hidden; transition: height var(--duration, 400ms) var(--easing-default); + + &--animating { + overflow: hidden; + } } diff --git a/packages/dnb-eufemia/src/components/height-animation/useHeightAnimation.tsx b/packages/dnb-eufemia/src/components/height-animation/useHeightAnimation.tsx index 644a08dc3e3..3142f103d37 100644 --- a/packages/dnb-eufemia/src/components/height-animation/useHeightAnimation.tsx +++ b/packages/dnb-eufemia/src/components/height-animation/useHeightAnimation.tsx @@ -2,20 +2,57 @@ import React from 'react' import AnimateHeight from '../../shared/AnimateHeight' type useHeightAnimationOptions = { + /** + * Set to `true` when the view should animate from 0px to auto. + * Default: false + */ open?: boolean + + /** + * Set to `false` to omit the animation. + * Default: true + */ animate?: boolean + + /** + * In order to let the Hook know when children has changed + */ + children?: React.ReactNode + + /** + * Is called when fully opened or closed + * Default: null + */ + onOpen?: (isOpen: boolean) => void + + /** + * Is called when animation is done and the full height has reached + * Default: null + */ + onAnimationEnd?: (state: HeightAnimationOnEndTypes) => void } -export type HeightAnimationOnStartTypes = 'opening' | 'closing' -export type HeightAnimationOnEndTypes = 'opened' | 'closed' +export type HeightAnimationOnStartTypes = + | 'opening' + | 'closing' + | 'adjusting' + +export type HeightAnimationOnEndTypes = 'opened' | 'closed' | 'adjusted' export function useHeightAnimation( targetRef: React.RefObject, - { open = null, animate = true }: useHeightAnimationOptions = {} + { + open = null, + animate = true, + children = null, + onOpen = null, + onAnimationEnd = null, + }: useHeightAnimationOptions = {} ) { const animRef = React.useRef(null) const [isOpen, setIsOpen] = React.useState(open) const [isVisible, setIsVisible] = React.useState(false) + const [isAnimating, setIsAnimating] = React.useState(false) const [isVisibleParallax, setParallax] = React.useState(open) const [isInitialRender, setIsMounted] = React.useState(true) @@ -28,16 +65,26 @@ export function useHeightAnimation( React.useLayoutEffect(() => { animRef.current = new AnimateHeight({ animate }) + if (isInitialRender && isOpen) { + onOpen?.(true) + } + if (animate) { animRef.current.onStart((state: HeightAnimationOnStartTypes) => { switch (state) { case 'opening': setIsVisible(true) setParallax(true) + setIsAnimating(true) break case 'closing': setParallax(false) + setIsAnimating(true) + break + + case 'adjusting': + setIsAnimating(true) break } }) @@ -46,20 +93,44 @@ export function useHeightAnimation( switch (state) { case 'opened': setIsOpen(true) + setIsAnimating(false) + onOpen?.(true) break case 'closed': setIsVisible(false) setIsOpen(false) setParallax(false) + setIsAnimating(false) + onOpen?.(false) + break + + case 'adjusted': + setIsAnimating(false) break } + + onAnimationEnd?.(state) }) } return () => animRef.current?.remove() - }, [animate]) + }, [animate]) // eslint-disable-line react-hooks/exhaustive-deps + + useOpenClose({ open, animRef, targetRef, isInitialRender }) + useAdjust({ children, animRef, isInitialRender }) + return { + open, + isOpen, + isInDOM: open || isVisible, + isVisible, + isVisibleParallax, + isAnimating, + } +} + +function useOpenClose({ open, animRef, targetRef, isInitialRender }) { React.useLayoutEffect(() => { if (!targetRef.current) { return // stop here @@ -77,12 +148,41 @@ export function useHeightAnimation( anim.close() } }, [open, targetRef, animRef]) // eslint-disable-line +} - return { - open, - isOpen, - isInDOM: open || isVisible, - isVisible, - isVisibleParallax, +function useAdjust({ children, animRef, isInitialRender }) { + const fromHeight = React.useRef(0) + + const shouldAdjust = () => { + switch (animRef.current?.state) { + case 'opened': + case 'adjusted': + case 'adjusting': + return !isInitialRender + } + + return false } + + React.useMemo(() => { + if (shouldAdjust()) { + fromHeight.current = animRef.current?.getHeight() + } + }, [children]) // eslint-disable-line react-hooks/exhaustive-deps + + React.useLayoutEffect(() => { + if (shouldAdjust()) { + /** + * Ensure we don't have height, while we get the "toHeight" again + * We may move this inside of the AnimateHeight class later, + * but the "GlobalStatus" is currently relaying on "getUnknownHeight" inside of adjustTo + */ + animRef.current.elem.style.height = '' + + animRef.current.adjustTo( + fromHeight.current, + animRef.current.getHeight() // use getHeight instead of getUnknownHeight because of the additional, disturbing DOM manipupation + ) + } + }, [children]) // eslint-disable-line react-hooks/exhaustive-deps } diff --git a/packages/dnb-eufemia/src/shared/AnimateHeight.ts b/packages/dnb-eufemia/src/shared/AnimateHeight.ts index fcdcc3b41e1..d279b0b6db6 100644 --- a/packages/dnb-eufemia/src/shared/AnimateHeight.ts +++ b/packages/dnb-eufemia/src/shared/AnimateHeight.ts @@ -152,7 +152,7 @@ export default class AnimateHeight { } } getHeight() { - return parseFloat(String(this.elem.clientHeight)) || null + return parseFloat(String(this.elem?.clientHeight)) || null } getWidth() { if (!this.isInBrowser) {