diff --git a/src/components/Form/DynamicField.tsx b/src/components/Form/DynamicField.tsx index 8806aa4..376cfe4 100644 --- a/src/components/Form/DynamicField.tsx +++ b/src/components/Form/DynamicField.tsx @@ -1,14 +1,21 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import * as React from 'react'; import type { DynamicFormField, FormDataType, PartialFormDataType } from '@douglasneuroinformatics/libui-form-types'; import { pick } from 'lodash-es'; +import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import { StaticField } from './StaticField.tsx'; import type { FormErrors } from './types.ts'; +/** + * The fields of a form are separated by a gap of 1.5rem (i.e., gap-6). A negative margin of the same + * size removes the gap of a collapsed field, so the fields below it do not jump when it is added or removed. + */ +const COLLAPSED_MARGIN_TOP = '-1.5rem'; + export type DynamicFieldProps = { errors: FormErrors; field: DynamicFormField; @@ -48,11 +55,49 @@ export const DynamicField = ({ } }, [field.deps, values]); - if (!staticField) { - return null; + const shouldReduceMotion = useReducedMotion(); + const transition = { duration: shouldReduceMotion ? 0 : 0.2, ease: 'easeInOut' } as const; + const collapsed = { height: 0, marginTop: COLLAPSED_MARGIN_TOP, opacity: 0, overflow: 'hidden' } as const; + + // retained so that the field can still be rendered while it animates out + const lastStaticFieldRef = useRef(staticField); + if (staticField) { + lastStaticFieldRef.current = staticField; } + const displayedField = staticField ?? lastStaticFieldRef.current; return ( - + + {staticField && displayedField && ( + + + + )} + ); }; diff --git a/src/components/Form/Form.test.tsx b/src/components/Form/Form.test.tsx index be37550..1548d77 100644 --- a/src/components/Form/Form.test.tsx +++ b/src/components/Form/Form.test.tsx @@ -62,14 +62,15 @@ describe('Form', () => { expect(() => screen.getByLabelText('Field B')).toThrow(); }); - it('should display field b after checking field a, then remove field b when a is unchecked', () => { + it('should display field b after checking field a, then remove field b when a is unchecked', async () => { const a = screen.getByLabelText('Field A'); expect(a).toHaveAttribute('data-state', 'unchecked'); fireEvent.click(a); expect(a).toHaveAttribute('data-state', 'checked'); expect(screen.getByLabelText('Field B')).toBeInTheDocument(); fireEvent.click(a); - expect(() => screen.getByLabelText('Field B')).toThrow(); + // the field is removed after it animates out + await waitFor(() => expect(screen.queryByLabelText('Field B')).not.toBeInTheDocument()); }); it('should allow submitting the form if field A is not checked', async () => { @@ -104,7 +105,7 @@ describe('Form', () => { await userEvent.type(b, 'TEST'); expect(b.value).toBe('TEST'); fireEvent.click(a); - expect(b).not.toBeInTheDocument(); + await waitFor(() => expect(b).not.toBeInTheDocument()); fireEvent.click(a); b = screen.getByLabelText('Field B'); expect(b).toBeInTheDocument(); @@ -399,4 +400,46 @@ describe('Form', () => { expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) }); }); }); + + describe('dynamic field animation', () => { + const renderForm = (initialValues?: { a?: boolean }) => { + render( +
(data.a ? { kind: 'string', label: 'Field B', variant: 'input' } : null) + } + }} + data-testid={testid} + initialValues={initialValues} + validationSchema={z.object({ a: z.boolean().optional(), b: z.string().optional() })} + onSubmit={onSubmit} + /> + ); + }; + + it('should not animate a dynamic field that is displayed when the form is first rendered', () => { + renderForm({ a: true }); + expect(screen.getByTestId('dynamic-field').style.opacity).not.toBe('0'); + }); + + it('should start collapsed and transparent when the field appears, then become fully visible', async () => { + renderForm(); + fireEvent.click(screen.getByLabelText('Field A')); + const wrapper = screen.getByTestId('dynamic-field'); + expect(wrapper.style.opacity).toBe('0'); + expect(wrapper.style.height).toBe('0px'); + await waitFor(() => expect(wrapper.style.opacity).toBe('1')); + }); + + it('should keep the field in the document while it animates out, then remove it', async () => { + renderForm({ a: true }); + fireEvent.click(screen.getByLabelText('Field A')); + expect(screen.getByLabelText('Field B')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByLabelText('Field B')).not.toBeInTheDocument()); + }); + }); });