feat: deprecates react-animate-height in favor of native css (#9456)

Deprecates `react-animate-height` in favor of native CSS, specifically
the `interpolate-size: allow-keywords;` property which can be used to
animate to `height: auto`—the primary reason this package exists. This
is one less dependency in our `node_modules`. Tried to replicate the
current DOM structure, class names, and API of `react-animate-height`
for best compatibility.

Note that this CSS property is experimental BUT this PR includes a patch
for browsers without native support. Once full support is reached, the
patch can be safely removed.
This commit is contained in:
Jacob Fletcher
2024-11-25 17:48:16 -05:00
committed by GitHub
parent 058bd02ebd
commit 0757e06e71
15 changed files with 196 additions and 68 deletions

View File

@@ -81,7 +81,6 @@ async function build() {
'react', 'react',
'react-dom', 'react-dom',
'next', 'next',
'react-animate-height',
'crypto', 'crypto',
'lodash', 'lodash',
'ui', 'ui',

View File

@@ -119,7 +119,6 @@ function require(m) {
'react', 'react',
'react-dom', 'react-dom',
'next', 'next',
'react-animate-height',
'crypto', 'crypto',
], ],
//packages: 'external', //packages: 'external',
@@ -162,7 +161,6 @@ function require(m) {
'react', 'react',
'react-dom', 'react-dom',
'next', 'next',
'react-animate-height',
'crypto', 'crypto',
'@floating-ui/react', '@floating-ui/react',
'date-fns', 'date-fns',

View File

@@ -110,7 +110,6 @@
"md5": "2.3.0", "md5": "2.3.0",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"qs-esm": "7.0.2", "qs-esm": "7.0.2",
"react-animate-height": "2.1.2",
"react-datepicker": "6.9.0", "react-datepicker": "6.9.0",
"react-image-crop": "10.1.8", "react-image-crop": "10.1.8",
"react-select": "5.8.0", "react-select": "5.8.0",

View File

@@ -0,0 +1,8 @@
.rah-static {
interpolate-size: allow-keywords;
height: 0;
&--height-auto {
height: auto;
}
}

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useRef } from 'react'
import { usePatchAnimateHeight } from './usePatchAnimateHeight.js'
import './index.scss'
export const AnimateHeight: React.FC<{
children: React.ReactNode
className?: string
duration?: number
height?: 'auto' | number
id?: string
}> = ({ id, children, className, duration = 300, height }) => {
const [open, setOpen] = React.useState(() => Boolean(height))
const prevIsOpen = useRef(open)
const [childrenDisplay, setChildrenDisplay] = React.useState<CSSStyleDeclaration['display']>(
() => (open ? '' : 'none'),
)
const [parentOverflow, setParentOverflow] = React.useState<CSSStyleDeclaration['overflow']>(() =>
open ? '' : 'hidden',
)
const [isAnimating, setIsAnimating] = React.useState(false)
useEffect(() => {
let displayTimer: number
let overflowTimer: number
const newIsOpen = Boolean(height)
const hasChanged = prevIsOpen.current !== newIsOpen
prevIsOpen.current = newIsOpen
if (hasChanged) {
setIsAnimating(true)
setParentOverflow('hidden')
if (newIsOpen) {
setChildrenDisplay('')
} else {
// `display: none` once closed
displayTimer = window.setTimeout(() => {
setChildrenDisplay('none')
}, duration)
}
setOpen(newIsOpen)
// reset overflow once open
overflowTimer = window.setTimeout(() => {
setParentOverflow('')
setIsAnimating(false)
}, duration)
}
return () => {
if (displayTimer) {
clearTimeout(displayTimer)
}
if (overflowTimer) {
clearTimeout(overflowTimer)
}
}
}, [height, duration])
const containerRef = useRef<HTMLDivElement>(null)
usePatchAnimateHeight({
containerRef,
duration,
open,
})
return (
<div
aria-hidden={!open}
className={[
className,
'rah-static',
open && height === 'auto' && 'rah-static--height-auto',
isAnimating && `rah-animating--${open ? 'down' : 'up'}`,
isAnimating && height === 'auto' && `rah-animating--to-height-auto`,
]
.filter(Boolean)
.join(' ')}
id={id}
ref={containerRef}
style={{
overflow: parentOverflow,
transition: `height ${duration}ms ease`,
}}
>
<div {...(childrenDisplay ? { style: { display: childrenDisplay } } : {})}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useMemo, useRef } from 'react'
export const usePatchAnimateHeight = ({
containerRef,
duration,
open,
}: {
containerRef: React.RefObject<HTMLDivElement>
duration: number
open: boolean
}): { browserSupportsKeywordAnimation: boolean } => {
const browserSupportsKeywordAnimation = useMemo(
() => (CSS.supports ? Boolean(CSS.supports('interpolate-size', 'allow-keywords')) : false),
[],
)
const previousOpenState = useRef(open)
useEffect(() => {
if (containerRef.current && !browserSupportsKeywordAnimation) {
const container = containerRef.current
const getTotalHeight = (el: HTMLDivElement) => {
const styles = window.getComputedStyle(el)
const marginTop = parseFloat(styles.marginTop)
const marginBottom = parseFloat(styles.marginBottom)
return el.scrollHeight + marginTop + marginBottom
}
const animate = () => {
const maxContentHeight = getTotalHeight(container)
// Set initial state
if (previousOpenState.current !== open) {
container.style.height = open ? '0px' : `${maxContentHeight}px`
}
// Trigger reflow
container.offsetHeight // eslint-disable-line @typescript-eslint/no-unused-expressions
// Start animation
container.style.transition = `height ${duration}ms ease`
container.style.height = open ? `${maxContentHeight}px` : '0px'
const transitionEndHandler = () => {
container.style.transition = ''
container.style.height = !open ? '0px' : 'auto'
container.removeEventListener('transitionend', transitionEndHandler)
}
container.addEventListener('transitionend', transitionEndHandler)
}
animate()
previousOpenState.current = open
return () => {
container.style.transition = ''
container.style.height = ''
}
}
}, [open, duration, containerRef, browserSupportsKeywordAnimation])
return { browserSupportsKeywordAnimation }
}

View File

@@ -4,11 +4,11 @@ import { useModal } from '@faceless-ui/modal'
import { useWindowInfo } from '@faceless-ui/window-info' import { useWindowInfo } from '@faceless-ui/window-info'
import { isImage } from 'payload/shared' import { isImage } from 'payload/shared'
import React from 'react' import React from 'react'
import AnimateHeightImport from 'react-animate-height'
import { ChevronIcon } from '../../../icons/Chevron/index.js' import { ChevronIcon } from '../../../icons/Chevron/index.js'
import { XIcon } from '../../../icons/X/index.js' import { XIcon } from '../../../icons/X/index.js'
import { useTranslation } from '../../../providers/Translation/index.js' import { useTranslation } from '../../../providers/Translation/index.js'
import { AnimateHeight } from '../../AnimateHeight/index.js'
import { Button } from '../../Button/index.js' import { Button } from '../../Button/index.js'
import { Drawer } from '../../Drawer/index.js' import { Drawer } from '../../Drawer/index.js'
import { ErrorPill } from '../../ErrorPill/index.js' import { ErrorPill } from '../../ErrorPill/index.js'
@@ -18,11 +18,8 @@ import { Thumbnail } from '../../Thumbnail/index.js'
import { Actions } from '../ActionsBar/index.js' import { Actions } from '../ActionsBar/index.js'
import { AddFilesView } from '../AddFilesView/index.js' import { AddFilesView } from '../AddFilesView/index.js'
import { useFormsManager } from '../FormsManager/index.js' import { useFormsManager } from '../FormsManager/index.js'
import { useBulkUpload } from '../index.js'
import './index.scss' import './index.scss'
import { useBulkUpload } from '../index.js'
const AnimateHeight = (AnimateHeightImport.default ||
AnimateHeightImport) as typeof AnimateHeightImport.default
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files' const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
@@ -125,7 +122,7 @@ export function FileSidebar() {
</div> </div>
<div className={`${baseClass}__animateWrapper`}> <div className={`${baseClass}__animateWrapper`}>
<AnimateHeight duration={200} height={!breakpoints.m || showFiles ? 'auto' : 0}> <AnimateHeight height={!breakpoints.m || showFiles ? 'auto' : 0}>
<div className={`${baseClass}__filesContainer`}> <div className={`${baseClass}__filesContainer`}>
{isInitializing && forms.length === 0 && initialFiles.length > 0 {isInitializing && forms.length === 0 && initialFiles.length > 0
? Array.from(initialFiles).map((file, index) => ( ? Array.from(initialFiles).map((file, index) => (

View File

@@ -1,16 +1,13 @@
'use client' 'use client'
import React, { useState } from 'react' import React, { useState } from 'react'
import AnimateHeightImport from 'react-animate-height'
import type { DragHandleProps } from '../DraggableSortable/DraggableSortableItem/types.js' import type { DragHandleProps } from '../DraggableSortable/DraggableSortableItem/types.js'
const AnimateHeight = (AnimateHeightImport.default ||
AnimateHeightImport) as typeof AnimateHeightImport.default
import { ChevronIcon } from '../../icons/Chevron/index.js' import { ChevronIcon } from '../../icons/Chevron/index.js'
import { DragHandleIcon } from '../../icons/DragHandle/index.js' import { DragHandleIcon } from '../../icons/DragHandle/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss' import './index.scss'
import { AnimateHeight } from '../AnimateHeight/index.js'
import { CollapsibleProvider, useCollapsible } from './provider.js' import { CollapsibleProvider, useCollapsible } from './provider.js'
const baseClass = 'collapsible' const baseClass = 'collapsible'
@@ -114,7 +111,7 @@ export const Collapsible: React.FC<Props> = ({
</div> </div>
</div> </div>
</div> </div>
<AnimateHeight duration={200} height={isCollapsed ? 0 : 'auto'}> <AnimateHeight height={isCollapsed ? 0 : 'auto'}>
<div className={`${baseClass}__content`}>{children}</div> <div className={`${baseClass}__content`}>{children}</div>
</AnimateHeight> </AnimateHeight>
</CollapsibleProvider> </CollapsibleProvider>

View File

@@ -4,16 +4,13 @@ import type { ClientCollectionConfig, Where } from 'payload'
import { useWindowInfo } from '@faceless-ui/window-info' import { useWindowInfo } from '@faceless-ui/window-info'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useEffect, useRef, useState } from 'react' import React, { Fragment, useEffect, useRef, useState } from 'react'
import AnimateHeightImport from 'react-animate-height'
const AnimateHeight = (AnimateHeightImport.default ||
AnimateHeightImport) as typeof AnimateHeightImport.default
import { useUseTitleField } from '../../hooks/useUseAsTitle.js' import { useUseTitleField } from '../../hooks/useUseAsTitle.js'
import { ChevronIcon } from '../../icons/Chevron/index.js' import { ChevronIcon } from '../../icons/Chevron/index.js'
import { SearchIcon } from '../../icons/Search/index.js' import { SearchIcon } from '../../icons/Search/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js' import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { AnimateHeight } from '../AnimateHeight/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js' import { ColumnSelector } from '../ColumnSelector/index.js'
import { DeleteMany } from '../DeleteMany/index.js' import { DeleteMany } from '../DeleteMany/index.js'
import { EditMany } from '../EditMany/index.js' import { EditMany } from '../EditMany/index.js'
@@ -23,8 +20,8 @@ import { SearchFilter } from '../SearchFilter/index.js'
import { UnpublishMany } from '../UnpublishMany/index.js' import { UnpublishMany } from '../UnpublishMany/index.js'
import { WhereBuilder } from '../WhereBuilder/index.js' import { WhereBuilder } from '../WhereBuilder/index.js'
import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js' import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js'
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js'
import './index.scss' import './index.scss'
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js'
const baseClass = 'list-controls' const baseClass = 'list-controls'

View File

@@ -2,16 +2,13 @@
import type { NavPreferences } from 'payload' import type { NavPreferences } from 'payload'
import React, { useState } from 'react' import React, { useState } from 'react'
import AnimateHeightImport from 'react-animate-height'
import { ChevronIcon } from '../../icons/Chevron/index.js' import { ChevronIcon } from '../../icons/Chevron/index.js'
import { usePreferences } from '../../providers/Preferences/index.js' import { usePreferences } from '../../providers/Preferences/index.js'
import './index.scss' import './index.scss'
import { AnimateHeight } from '../AnimateHeight/index.js'
import { useNav } from '../Nav/context.js' import { useNav } from '../Nav/context.js'
const AnimateHeight = (AnimateHeightImport.default ||
AnimateHeightImport) as typeof AnimateHeightImport.default
const baseClass = 'nav-group' const baseClass = 'nav-group'
type Props = { type Props = {

View File

@@ -7,12 +7,8 @@ import type {
Where, Where,
} from 'payload' } from 'payload'
import React, { Fragment, useCallback, useState } from 'react'
import AnimateHeightImport from 'react-animate-height'
const AnimateHeight = AnimateHeightImport.default || AnimateHeightImport
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback, useState } from 'react'
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' import type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
import type { Column } from '../Table/index.js' import type { Column } from '../Table/index.js'
@@ -27,12 +23,13 @@ import { ListQueryProvider } from '../../providers/ListQuery/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js'
import { AnimateHeight } from '../AnimateHeight/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js' import { ColumnSelector } from '../ColumnSelector/index.js'
import { useDocumentDrawer } from '../DocumentDrawer/index.js' import { useDocumentDrawer } from '../DocumentDrawer/index.js'
import { RelationshipProvider } from '../Table/RelationshipProvider/index.js' import { RelationshipProvider } from '../Table/RelationshipProvider/index.js'
import { TableColumnsProvider } from '../TableColumns/index.js' import { TableColumnsProvider } from '../TableColumns/index.js'
import { DrawerLink } from './cells/DrawerLink/index.js'
import './index.scss' import './index.scss'
import { DrawerLink } from './cells/DrawerLink/index.js'
import { RelationshipTablePagination } from './Pagination.js' import { RelationshipTablePagination } from './Pagination.js'
const baseClass = 'relationship-table' const baseClass = 'relationship-table'
@@ -239,7 +236,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
}} }}
tableAppearance="condensed" tableAppearance="condensed"
> >
{/* @ts-expect-error TODO: get this CJS import to work, eslint keeps removing the type assertion */}
<AnimateHeight <AnimateHeight
className={`${baseClass}__columns`} className={`${baseClass}__columns`}
height={openColumnSelector ? 'auto' : 0} height={openColumnSelector ? 'auto' : 0}

17
pnpm-lock.yaml generated
View File

@@ -1524,9 +1524,6 @@ importers:
react: react:
specifier: 19.0.0-rc-65a56d0e-20241020 specifier: 19.0.0-rc-65a56d0e-20241020
version: 19.0.0-rc-65a56d0e-20241020 version: 19.0.0-rc-65a56d0e-20241020
react-animate-height:
specifier: 2.1.2
version: 2.1.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
react-datepicker: react-datepicker:
specifier: 6.9.0 specifier: 6.9.0
version: 6.9.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020) version: 6.9.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
@@ -8600,13 +8597,6 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
react-animate-height@2.1.2:
resolution: {integrity: sha512-A9jfz/4CTdsIsE7WCQtO9UkOpMBcBRh8LxyHl2eoZz1ki02jpyUL5xt58gabd0CyeLQ8fRyQ+s2lyV2Ufu8Owg==}
engines: {node: '>= 6.0.0'}
peerDependencies:
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020
react-datepicker@6.9.0: react-datepicker@6.9.0:
resolution: {integrity: sha512-QTxuzeem7BUfVFWv+g5WuvzT0c5BPo+XTCNbMTZKSZQLU+cMMwSUHwspaxuIcDlwNcOH0tiJ+bh1fJ2yxOGYWA==} resolution: {integrity: sha512-QTxuzeem7BUfVFWv+g5WuvzT0c5BPo+XTCNbMTZKSZQLU+cMMwSUHwspaxuIcDlwNcOH0tiJ+bh1fJ2yxOGYWA==}
peerDependencies: peerDependencies:
@@ -18649,13 +18639,6 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
react-animate-height@2.1.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
dependencies:
classnames: 2.5.1
prop-types: 15.8.1
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-datepicker@6.9.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020): react-datepicker@6.9.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
dependencies: dependencies:
'@floating-ui/react': 0.26.27(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020) '@floating-ui/react': 0.26.27(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)

View File

@@ -378,35 +378,27 @@ describe('admin1', () => {
await page.goto(postsUrl.admin) await page.goto(postsUrl.admin)
await page.waitForURL(postsUrl.admin) await page.waitForURL(postsUrl.admin)
await openNav(page) await openNav(page)
const navGroup = page.locator('#nav-group-One .nav-group__toggle') const navGroup = page.locator('#nav-group-One .nav-group__toggle')
const link = page.locator('#nav-group-one-collection-ones')
await expect(navGroup).toContainText('One') await expect(navGroup).toContainText('One')
await expect(link).toBeVisible() const button = page.locator('#nav-group-one-collection-ones')
await expect(button).toBeVisible()
await navGroup.click() await navGroup.click()
await expect(link).toBeHidden() await expect(button).toBeHidden()
await navGroup.click() await navGroup.click()
await expect(link).toBeVisible() await expect(button).toBeVisible()
}) })
test('nav — should collapse and expand globals groups', async () => { test('nav — should collapse and expand globals groups', async () => {
await page.goto(postsUrl.admin) await page.goto(postsUrl.admin)
await openNav(page) await openNav(page)
const navGroup = page.locator('#nav-group-Group .nav-group__toggle') const navGroup = page.locator('#nav-group-Group .nav-group__toggle')
const link = page.locator('#nav-global-group-globals-one')
await expect(navGroup).toContainText('Group') await expect(navGroup).toContainText('Group')
await expect(link).toBeVisible() const button = page.locator('#nav-global-group-globals-one')
await expect(button).toBeVisible()
await navGroup.click() await navGroup.click()
await expect(link).toBeHidden() await expect(button).toBeHidden()
await navGroup.click() await navGroup.click()
await expect(link).toBeVisible() await expect(button).toBeVisible()
}) })
test('nav — should save group collapse preferences', async () => { test('nav — should save group collapse preferences', async () => {

View File

@@ -125,9 +125,9 @@ export interface Config {
user: User & { user: User & {
collection: 'users'; collection: 'users';
}; };
jobs: { jobs?: {
tasks: unknown; tasks: unknown;
workflows: unknown; workflows?: unknown;
}; };
} }
export interface UserAuthOperations { export interface UserAuthOperations {

View File

@@ -75,9 +75,9 @@ export interface Config {
user: User & { user: User & {
collection: 'users'; collection: 'users';
}; };
jobs: { jobs?: {
tasks: unknown; tasks: unknown;
workflows: unknown; workflows?: unknown;
}; };
} }
export interface UserAuthOperations { export interface UserAuthOperations {
@@ -105,6 +105,7 @@ export interface UserAuthOperations {
export interface Post { export interface Post {
id: string; id: string;
title?: string | null; title?: string | null;
conditionalField?: string | null;
isFiltered?: boolean | null; isFiltered?: boolean | null;
restrictedField?: string | null; restrictedField?: string | null;
upload?: (string | null) | Upload; upload?: (string | null) | Upload;
@@ -388,6 +389,7 @@ export interface PayloadMigration {
*/ */
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
conditionalField?: T;
isFiltered?: T; isFiltered?: T;
restrictedField?: T; restrictedField?: T;
upload?: T; upload?: T;