Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions src/components/Form/DynamicField.tsx
Original file line number Diff line number Diff line change
@@ -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<TData extends FormDataType> = {
errors: FormErrors<TData>;
field: DynamicFormField<TData>;
Expand Down Expand Up @@ -48,11 +55,49 @@ export const DynamicField = <TData extends FormDataType>({
}
}, [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 {...props} field={staticField} name={name} readOnly={readOnly} setValues={setValues} values={values} />
<AnimatePresence initial={false}>
{staticField && displayedField && (
<motion.div
animate={{
height: 'auto',
marginTop: 0,
opacity: 1,
// not clipped once displayed, so that focus rings are fully visible
overflow: 'visible',
pointerEvents: 'auto',
transition: { ...transition, overflow: { delay: transition.duration, duration: 0 } }
}}
data-testid="dynamic-field"
exit={{
...collapsed,
pointerEvents: 'none',
transition: { ...transition, overflow: { duration: 0 } }
}}
initial={collapsed}
key={name}
>
<StaticField
{...props}
field={displayedField}
name={name}
readOnly={readOnly}
setValues={setValues}
values={values}
/>
</motion.div>
)}
</AnimatePresence>
);
};
49 changes: 46 additions & 3 deletions src/components/Form/Form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -399,4 +400,46 @@ describe('Form', () => {
expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) });
});
});

describe('dynamic field animation', () => {
const renderForm = (initialValues?: { a?: boolean }) => {
render(
<Form
content={{
a: { kind: 'boolean', label: 'Field A', variant: 'checkbox' },
b: {
deps: ['a'],
kind: 'dynamic',
render: (data) => (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());
});
});
});
Loading