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:
@@ -81,7 +81,6 @@ async function build() {
|
||||
'react',
|
||||
'react-dom',
|
||||
'next',
|
||||
'react-animate-height',
|
||||
'crypto',
|
||||
'lodash',
|
||||
'ui',
|
||||
|
||||
@@ -119,7 +119,6 @@ function require(m) {
|
||||
'react',
|
||||
'react-dom',
|
||||
'next',
|
||||
'react-animate-height',
|
||||
'crypto',
|
||||
],
|
||||
//packages: 'external',
|
||||
@@ -162,7 +161,6 @@ function require(m) {
|
||||
'react',
|
||||
'react-dom',
|
||||
'next',
|
||||
'react-animate-height',
|
||||
'crypto',
|
||||
'@floating-ui/react',
|
||||
'date-fns',
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
"md5": "2.3.0",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"qs-esm": "7.0.2",
|
||||
"react-animate-height": "2.1.2",
|
||||
"react-datepicker": "6.9.0",
|
||||
"react-image-crop": "10.1.8",
|
||||
"react-select": "5.8.0",
|
||||
|
||||
8
packages/ui/src/elements/AnimateHeight/index.scss
Normal file
8
packages/ui/src/elements/AnimateHeight/index.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.rah-static {
|
||||
interpolate-size: allow-keywords;
|
||||
height: 0;
|
||||
|
||||
&--height-auto {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
97
packages/ui/src/elements/AnimateHeight/index.tsx
Normal file
97
packages/ui/src/elements/AnimateHeight/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import { useModal } from '@faceless-ui/modal'
|
||||
import { useWindowInfo } from '@faceless-ui/window-info'
|
||||
import { isImage } from 'payload/shared'
|
||||
import React from 'react'
|
||||
import AnimateHeightImport from 'react-animate-height'
|
||||
|
||||
import { ChevronIcon } from '../../../icons/Chevron/index.js'
|
||||
import { XIcon } from '../../../icons/X/index.js'
|
||||
import { useTranslation } from '../../../providers/Translation/index.js'
|
||||
import { AnimateHeight } from '../../AnimateHeight/index.js'
|
||||
import { Button } from '../../Button/index.js'
|
||||
import { Drawer } from '../../Drawer/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 { AddFilesView } from '../AddFilesView/index.js'
|
||||
import { useFormsManager } from '../FormsManager/index.js'
|
||||
import { useBulkUpload } from '../index.js'
|
||||
import './index.scss'
|
||||
|
||||
const AnimateHeight = (AnimateHeightImport.default ||
|
||||
AnimateHeightImport) as typeof AnimateHeightImport.default
|
||||
import { useBulkUpload } from '../index.js'
|
||||
|
||||
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
|
||||
|
||||
@@ -125,7 +122,7 @@ export function FileSidebar() {
|
||||
</div>
|
||||
|
||||
<div className={`${baseClass}__animateWrapper`}>
|
||||
<AnimateHeight duration={200} height={!breakpoints.m || showFiles ? 'auto' : 0}>
|
||||
<AnimateHeight height={!breakpoints.m || showFiles ? 'auto' : 0}>
|
||||
<div className={`${baseClass}__filesContainer`}>
|
||||
{isInitializing && forms.length === 0 && initialFiles.length > 0
|
||||
? Array.from(initialFiles).map((file, index) => (
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import AnimateHeightImport from 'react-animate-height'
|
||||
|
||||
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 { DragHandleIcon } from '../../icons/DragHandle/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import './index.scss'
|
||||
import { AnimateHeight } from '../AnimateHeight/index.js'
|
||||
import { CollapsibleProvider, useCollapsible } from './provider.js'
|
||||
|
||||
const baseClass = 'collapsible'
|
||||
@@ -114,7 +111,7 @@ export const Collapsible: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimateHeight duration={200} height={isCollapsed ? 0 : 'auto'}>
|
||||
<AnimateHeight height={isCollapsed ? 0 : 'auto'}>
|
||||
<div className={`${baseClass}__content`}>{children}</div>
|
||||
</AnimateHeight>
|
||||
</CollapsibleProvider>
|
||||
|
||||
@@ -4,16 +4,13 @@ import type { ClientCollectionConfig, Where } from 'payload'
|
||||
import { useWindowInfo } from '@faceless-ui/window-info'
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
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 { ChevronIcon } from '../../icons/Chevron/index.js'
|
||||
import { SearchIcon } from '../../icons/Search/index.js'
|
||||
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { AnimateHeight } from '../AnimateHeight/index.js'
|
||||
import { ColumnSelector } from '../ColumnSelector/index.js'
|
||||
import { DeleteMany } from '../DeleteMany/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 { WhereBuilder } from '../WhereBuilder/index.js'
|
||||
import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js'
|
||||
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js'
|
||||
import './index.scss'
|
||||
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js'
|
||||
|
||||
const baseClass = 'list-controls'
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
import type { NavPreferences } from 'payload'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import AnimateHeightImport from 'react-animate-height'
|
||||
|
||||
import { ChevronIcon } from '../../icons/Chevron/index.js'
|
||||
import { usePreferences } from '../../providers/Preferences/index.js'
|
||||
import './index.scss'
|
||||
import { AnimateHeight } from '../AnimateHeight/index.js'
|
||||
import { useNav } from '../Nav/context.js'
|
||||
|
||||
const AnimateHeight = (AnimateHeightImport.default ||
|
||||
AnimateHeightImport) as typeof AnimateHeightImport.default
|
||||
|
||||
const baseClass = 'nav-group'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -7,12 +7,8 @@ import type {
|
||||
Where,
|
||||
} 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 React, { Fragment, useCallback, useState } from 'react'
|
||||
|
||||
import type { DocumentDrawerProps } from '../DocumentDrawer/types.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 { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js'
|
||||
import { AnimateHeight } from '../AnimateHeight/index.js'
|
||||
import { ColumnSelector } from '../ColumnSelector/index.js'
|
||||
import { useDocumentDrawer } from '../DocumentDrawer/index.js'
|
||||
import { RelationshipProvider } from '../Table/RelationshipProvider/index.js'
|
||||
import { TableColumnsProvider } from '../TableColumns/index.js'
|
||||
import { DrawerLink } from './cells/DrawerLink/index.js'
|
||||
import './index.scss'
|
||||
import { DrawerLink } from './cells/DrawerLink/index.js'
|
||||
import { RelationshipTablePagination } from './Pagination.js'
|
||||
|
||||
const baseClass = 'relationship-table'
|
||||
@@ -239,7 +236,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
||||
}}
|
||||
tableAppearance="condensed"
|
||||
>
|
||||
{/* @ts-expect-error TODO: get this CJS import to work, eslint keeps removing the type assertion */}
|
||||
<AnimateHeight
|
||||
className={`${baseClass}__columns`}
|
||||
height={openColumnSelector ? 'auto' : 0}
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -1524,9 +1524,6 @@ importers:
|
||||
react:
|
||||
specifier: 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:
|
||||
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)
|
||||
@@ -8600,13 +8597,6 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-QTxuzeem7BUfVFWv+g5WuvzT0c5BPo+XTCNbMTZKSZQLU+cMMwSUHwspaxuIcDlwNcOH0tiJ+bh1fJ2yxOGYWA==}
|
||||
peerDependencies:
|
||||
@@ -18649,13 +18639,6 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -378,35 +378,27 @@ describe('admin1', () => {
|
||||
await page.goto(postsUrl.admin)
|
||||
await page.waitForURL(postsUrl.admin)
|
||||
await openNav(page)
|
||||
|
||||
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(link).toBeVisible()
|
||||
|
||||
const button = page.locator('#nav-group-one-collection-ones')
|
||||
await expect(button).toBeVisible()
|
||||
await navGroup.click()
|
||||
await expect(link).toBeHidden()
|
||||
|
||||
await expect(button).toBeHidden()
|
||||
await navGroup.click()
|
||||
await expect(link).toBeVisible()
|
||||
await expect(button).toBeVisible()
|
||||
})
|
||||
|
||||
test('nav — should collapse and expand globals groups', async () => {
|
||||
await page.goto(postsUrl.admin)
|
||||
await openNav(page)
|
||||
|
||||
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(link).toBeVisible()
|
||||
|
||||
const button = page.locator('#nav-global-group-globals-one')
|
||||
await expect(button).toBeVisible()
|
||||
await navGroup.click()
|
||||
await expect(link).toBeHidden()
|
||||
|
||||
await expect(button).toBeHidden()
|
||||
await navGroup.click()
|
||||
await expect(link).toBeVisible()
|
||||
await expect(button).toBeVisible()
|
||||
})
|
||||
|
||||
test('nav — should save group collapse preferences', async () => {
|
||||
|
||||
@@ -125,9 +125,9 @@ export interface Config {
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
jobs?: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
workflows?: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
|
||||
@@ -75,9 +75,9 @@ export interface Config {
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
jobs?: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
workflows?: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
@@ -105,6 +105,7 @@ export interface UserAuthOperations {
|
||||
export interface Post {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
conditionalField?: string | null;
|
||||
isFiltered?: boolean | null;
|
||||
restrictedField?: string | null;
|
||||
upload?: (string | null) | Upload;
|
||||
@@ -388,6 +389,7 @@ export interface PayloadMigration {
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
conditionalField?: T;
|
||||
isFiltered?: T;
|
||||
restrictedField?: T;
|
||||
upload?: T;
|
||||
|
||||
Reference in New Issue
Block a user