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
65 changes: 65 additions & 0 deletions lib/components/Collapse/Collapse.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';

import { Button } from '../Button/Button';
import { Card } from '../Card/Card';
import { Select } from '../Select/Select';

import { Collapse as CollapseComponent } from './Collapse';

type Story = StoryObj<typeof CollapseComponent>;

const meta = {
title: 'In Review/Collapse',
component: CollapseComponent,
} satisfies Meta<typeof CollapseComponent>;

export const Collapse: Story = {
parameters: {
docs: {
description: {
story:
'Animates height between collapsed and expanded, honours `prefers-reduced-motion`, and only clips overflow while animating so a Select inside can open freely. `keepMounted` keeps the content in the DOM (hidden and inert) so `aria-controls` always resolves. Compute, settings and kubernetes each carried their own version.',
},
},
},
render: function CollapseStory() {
const [isOpen, setIsOpen] = useState(true);

return (
<Card className="flex flex-col gap-4 max-w-100">
<Button
variant="tertiary"
aria-expanded={isOpen}
aria-controls="advanced-options"
onClick={() => setIsOpen((current) => !current)}
>
{isOpen ? 'Hide' : 'Show'} advanced options
</Button>

<CollapseComponent id="advanced-options" isOpen={isOpen} keepMounted>
<div className="flex flex-col gap-4 pt-2">
<Select
label="Network"
placeholder="Pick a network"
options={[
{ label: 'default', value: 'default' },
{ label: 'staging', value: 'staging' },
]}
/>
<Select
label="Firewall"
placeholder="Pick a firewall"
options={[
{ label: 'default', value: 'default' },
{ label: 'web', value: 'web' },
]}
/>
</div>
</CollapseComponent>
</Card>
);
},
};

export default meta;
97 changes: 97 additions & 0 deletions lib/components/Collapse/Collapse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { render, screen, waitFor } from '@testing-library/react';
import { axe } from 'jest-axe';
import { MotionGlobalConfig } from 'motion/react';

import { Collapse } from './Collapse';

describe('Collapse', () => {
beforeAll(() => {
MotionGlobalConfig.skipAnimations = true;
});

afterAll(() => {
MotionGlobalConfig.skipAnimations = false;
});

it('should render the children while open and remove them once closed', async () => {
const onExitComplete = vi.fn();
const { rerender } = render(
<Collapse isOpen onExitComplete={onExitComplete}>
<p>Advanced options</p>
</Collapse>,
);

expect(screen.getByText('Advanced options')).toBeInTheDocument();

rerender(
<Collapse isOpen={false} onExitComplete={onExitComplete}>
<p>Advanced options</p>
</Collapse>,
);

await waitFor(() => {
expect(screen.queryByText('Advanced options')).not.toBeInTheDocument();
});
expect(onExitComplete).toHaveBeenCalledTimes(1);
});

it('should not render the children when initially closed', () => {
render(
<Collapse isOpen={false}>
<p>Advanced options</p>
</Collapse>,
);

expect(screen.queryByText('Advanced options')).not.toBeInTheDocument();
});

it('should keep the children mounted but hidden with keepMounted', async () => {
const { rerender } = render(
<>
<button type="button" aria-expanded aria-controls="advanced">
Toggle
</button>
<Collapse id="advanced" isOpen keepMounted>
<p>Advanced options</p>
</Collapse>
</>,
);

expect(screen.getByText('Advanced options')).toBeVisible();
expect(screen.getByRole('button')).toHaveAttribute(
'aria-controls',
'advanced',
);

rerender(
<>
<button type="button" aria-expanded={false} aria-controls="advanced">
Toggle
</button>
<Collapse id="advanced" isOpen={false} keepMounted>
<p>Advanced options</p>
</Collapse>
</>,
);

await waitFor(() => {
expect(screen.getByText('Advanced options')).not.toBeVisible();
});
expect(document.getElementById('advanced')).toHaveAttribute(
'aria-hidden',
'true',
);
});

it("shouldn't have accessibility violations", async () => {
const { container } = render(
<Collapse isOpen>
<p>Advanced options</p>
</Collapse>,
);

const results = await axe(container);

expect(results).toHaveNoViolations();
});
});
89 changes: 89 additions & 0 deletions lib/components/Collapse/Collapse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { FC, useState } from 'react';

import { cn } from '@/utils';

import { Props } from './Collapse.types';

const EASE = [0.4, 0, 0.2, 1] as const;

/**
* Animates its children between a collapsed and an expanded height,
* respecting `prefers-reduced-motion`. Overflow is only clipped while the
* animation runs, so dropdowns or tooltips inside the content are never cut.
*
* @example
* ```tsx
* <Button aria-expanded={isOpen} aria-controls="advanced" onClick={toggle}>
* Advanced options
* </Button>
* <Collapse id="advanced" isOpen={isOpen} keepMounted>
* <AdvancedOptions />
* </Collapse>
* ```
*/
const Collapse: FC<Props> = ({
children,
className,
collapsedMarginTop = 0,
id,
isOpen,
keepMounted = false,
onExitComplete,
}) => {
const prefersReducedMotion = useReducedMotion();
const [isAnimating, setIsAnimating] = useState(false);
const transition = prefersReducedMotion
? { duration: 0 }
: {
height: { duration: 0.35, ease: EASE },
marginTop: { duration: 0.35, ease: EASE },
opacity: { duration: 0.25, ease: EASE },
};
const collapsed = { opacity: 0, height: 0, marginTop: collapsedMarginTop };
const expanded = { opacity: 1, height: 'auto', marginTop: 0 };
const overflowClassName =
isOpen && !isAnimating ? 'overflow-visible' : 'overflow-hidden';

if (keepMounted) {
return (
<motion.div
id={id}
initial={false}
animate={isOpen ? expanded : collapsed}
transition={transition}
hidden={!isOpen && !isAnimating}
aria-hidden={!isOpen || undefined}
className={cn(overflowClassName, className)}
onAnimationStart={() => setIsAnimating(true)}
onAnimationComplete={() => setIsAnimating(false)}
>
{children}
</motion.div>
);
}

return (
<AnimatePresence initial={false} onExitComplete={onExitComplete}>
{isOpen ? (
<motion.div
key="collapse"
id={id}
initial={collapsed}
animate={expanded}
exit={collapsed}
transition={transition}
className={cn(overflowClassName, className)}
onAnimationStart={() => setIsAnimating(true)}
onAnimationComplete={() => setIsAnimating(false)}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
);
};

Collapse.displayName = 'KonstructCollapse';

export { Collapse };
19 changes: 19 additions & 0 deletions lib/components/Collapse/Collapse.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ReactNode } from 'react';

export type Props = {
children: ReactNode;
/** Additional CSS classes for the animated wrapper */
className?: string;
/** Margin applied while collapsed, so the closed state does not leave a gap */
collapsedMarginTop?: number | string;
/** Id of the animated region, to reference it from `aria-controls` */
id?: string;
/** Whether the content is expanded */
isOpen: boolean;
/** Keep the children mounted while collapsed (hidden and inert) instead of unmounting them */
keepMounted?: boolean;
/** Fired once the collapse animation has finished and the children are unmounted */
onExitComplete?: () => void;
};

export type CollapseProps = Props;
1 change: 1 addition & 0 deletions lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './Button/Button';
export * from './ButtonGroup/ButtonGroup';
export * from './Card/Card';
export * from './Checkbox/Checkbox';
export * from './Collapse/Collapse';
export * from './CopyButton/CopyButton';
export type { Props as CopyButtonProps } from './CopyButton/CopyButton.types';
export * from './Counter/Counter';
Expand Down
Loading