Skip to content

Commit

Permalink
Update readme, API (#46)
Browse files Browse the repository at this point in the history
* Update readme, API

* fix details

* fix details again

* fix details again

* fix details again

* mostly name fixes

* fix ref issue
  • Loading branch information
roginfarrer authored Apr 12, 2020
1 parent b67e76c commit 29aee67
Show file tree
Hide file tree
Showing 12 changed files with 231 additions and 144 deletions.
88 changes: 56 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![npm version][npm-badge]][npm-version]
[![Documentation Netlify Status][netlify-badge]][netlify]

A custom hook for creating flexible and accessible expand/collapse components in React.
A custom hook for creating accessible expand/collapse components in React. Animates the height using CSS transitions from `0` to `auto`.

## v3

Expand All @@ -14,9 +14,10 @@ This master branch now reflects the development of the next major release of thi
## Features

- Handles the height of animations of your elements, `auto` included!
- You control the UI - `useCollapse` provides the necessary props, you control everything else.
- Built with accessibility in mind - no need to worry if your collapse/expand component is accessible, since this takes care of it for you!
- You control the UI - `useCollapse` provides the necessary props, you control the styles and the elements.
- Accessible out of the box - no need to worry if your collapse/expand component is accessible, since this takes care of it for you!
- No animation framework required! Simply powered by CSS animations
- Written in TypeScript

## Demo

Expand All @@ -39,13 +40,15 @@ import React from 'react';
import useCollapse from 'react-collapsed';

function Demo() {
const { getCollapseProps, getToggleProps, isOpen } = useCollapse();
const { getCollapseProps, getToggleProps, isExpanded } = useCollapse();

return (
<Fragment>
<button {...getToggleProps()}>{isOpen ? 'Collapse' : 'Expand'}</button>
<div>
<button {...getToggleProps()}>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
<section {...getCollapseProps()}>Collapsed content 🙈</section>
</Fragment>
</div>
);
}
```
Expand All @@ -57,20 +60,20 @@ import React, { useState } from 'react';
import useCollapse from 'react-collapsed';

function Demo() {
const [isOpen, setOpen] = useState(false);
const { getCollapseProps, getToggleProps } = useCollapse({ isOpen });
const [isExpanded, setOpen] = useState(false);
const { getCollapseProps, getToggleProps } = useCollapse({ isExpanded });

return (
<Fragment>
<div>
<button
{...getToggleProps({
onClick: () => setOpen(oldOpen => !oldOpen),
})}
>
{isOpen ? 'Collapse' : 'Expand'}
{isExpanded ? 'Collapse' : 'Expand'}
</button>
<section {...getCollapseProps()}>Collapsed content 🙈</section>
</Fragment>
</div>
);
}
```
Expand All @@ -81,57 +84,78 @@ function Demo() {
const {
getCollapseProps,
getToggleProps,
isOpen,
isExpanded,
toggleOpen,
mountChildren,
} = useCollapse({
isOpen: boolean,
defaultOpen: boolean,
isExpanded: boolean,
defaultExpanded: boolean,
expandStyles: {},
collapseStyles: {},
collapsedHeight: 0,
easing: string,
duration: number,
});
```

### `useCollapse` Config

The following are optional properties passed into `useCollapse({ })`:

| Prop | Type | Default | Description |
| --------------- | ------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| isOpen | boolean | `undefined` | If true, the Collapse is expanded |
| defaultOpen | boolean | `false` | If true, the Collapse will be expanded when mounted |
| expandStyles | object | `{transitionDuration: '500ms', transitionTimingFunction: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)'}` | Style object applied to the collapse panel when it expands |
| collapseStyles | object | `{transitionDuration: '500ms', transitionTimingFunction: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)'}` | Style object applied to the collapse panel when it collapses |
| collapsedHeight | number | `0` | The height of the content when collapsed |
| Prop | Type | Default | Description |
| --------------- | ------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| isExpanded | boolean | `undefined` | If true, the Collapse is expanded |
| defaultExpanded | boolean | `false` | If true, the Collapse will be expanded when mounted |
| expandStyles | object | `{}` | Style object applied to the collapse panel when it expands |
| collapseStyles | object | `{}` | Style object applied to the collapse panel when it collapses |
| collapsedHeight | number | `0` | The height of the content when collapsed |
| easing | string | `cubic-bezier(0.4, 0, 0.2, 1)` | The transition timing function for the animation |
| duration | number | `undefined` | The duration of the animation in milliseconds. By default, the duration is programmatically calculated based on the height of the collapsed element |

### What you get

| Name | Description |
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
| getCollapseProps | Function that returns a prop object, which should be spread onto the collapse element |
| getToggleProps | Function that returns a prop object, which should be spread onto an element that toggles the collapse panel |
| isOpen | Whether or not the collapse is open (if not controlled) |
| toggleOpen | Function that will toggle the state of the collapse panel |
| isExpanded | Whether or not the collapse is expanded (if not controlled) |
| toggleExpanded | Function that will toggle the expanded state of the collapse panel |
| mountChildren | Whether or not the collapse panel content should be visible |

## Alternative Solutions

- [react-spring](https://www.react-spring.io/) - JavaScript animation based library that can potentially have smoother animations.
- [react-spring](https://www.react-spring.io/) - JavaScript animation based library that can potentially have smoother animations. Requires a bit more work to create an accessible collapse component.
- [react-animate-height](https://github.com/Stanko/react-animate-height/) - Another library that uses CSS transitions to animate to any height. It provides components, not a hook.

## Possible Issues
## FAQ

- Applying padding to the collapse block (the element receiving `getCollapseProps`) can lead to infinite animations and state updates. [14](https://github.com/roginfarrer/react-collapsed/issues/14)
<details>
<summary>When I apply vertical <code>padding</code> to the component that gets <code>getCollapseProps</code>, the animation is janky and it doesn't collapse all the way. What gives?</summary>

**Solution:** Apply the padding to a child element instead.
The collapse works by manipulating the `height` property. If an element has vertical padding, that padding expandes the size of the element, even if it has `height: 0; overflow: hidden`.

[minzipped-badge]: https://img.shields.io/bundlephobia/minzip/react-collapsed/latest
To avoid this, simply move that padding from the element to an element directly nested within in.

[npm-badge]: http://img.shields.io/npm/v/react-collapsed.svg?style=flat
[npm-version]: https://npmjs.org/package/react-collapsed "View this project on npm"
```javascript
// from
<div {...getCollapseProps({style: {padding: 20}})}
This will do weird things
</div>

// to
<div {...getCollapseProps()}
<div style={{padding: 20}}>
Much better!
</div>
</div>
```

</details>

[minzipped-badge]: https://img.shields.io/bundlephobia/minzip/react-collapsed/latest
[npm-badge]: http://img.shields.io/npm/v/react-collapsed.svg?style=flat
[npm-version]: https://npmjs.org/package/react-collapsed 'View this project on npm'
[ci-badge]: https://github.com/roginfarrer/react-collapsed/workflows/CI/badge.svg
[ci]: https://github.com/roginfarrer/react-collapsed/actions?query=workflow%3ACI+branch%3Amaster

[netlify]: https://app.netlify.com/sites/react-collapsed-next/deploys
[netlify-badge]: https://api.netlify.com/api/v1/badges/4d285ffc-aa4f-4d32-8549-eb58e00dd2d1/deploy-status
8 changes: 4 additions & 4 deletions example/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import useCollapse from '../src';
const collapseStyles = { background: 'blue', color: 'white' };

export const Uncontrolled = () => {
const { getCollapseProps, getToggleProps, isOpen } = useCollapse({
defaultOpen: true,
isOpen: false,
const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({
defaultExpanded: true,
isExpanded: false,
});

return (
<div>
<button {...getToggleProps({ style: { marginRight: 4 } })}>
{isOpen ? 'Close' : 'Open'}
{isExpanded ? 'Close' : 'Open'}
</button>
<div
{...getCollapseProps({
Expand Down
28 changes: 14 additions & 14 deletions src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ describe('useControlledState', () => {
let hookReturn: [boolean, () => void];

function UseControlledState({
defaultOpen,
isOpen,
defaultExpanded,
isExpanded,
}: {
defaultOpen?: boolean;
isOpen?: boolean;
defaultExpanded?: boolean;
isExpanded?: boolean;
}) {
const result = useControlledState({ defaultOpen, isOpen });
const result = useControlledState({ defaultExpanded, isExpanded });

hookReturn = result;

Expand All @@ -47,13 +47,13 @@ describe('useControlledState', () => {
});

it('returns the defaultValue value', () => {
render(<UseControlledState defaultOpen />);
render(<UseControlledState defaultExpanded />);

expect(hookReturn[0]).toBe(true);
});

it('setter toggles the value', () => {
render(<UseControlledState defaultOpen />);
render(<UseControlledState defaultExpanded />);

expect(hookReturn[0]).toBe(true);

Expand All @@ -77,29 +77,29 @@ describe('useControlledState', () => {
consoleOutput = [];
});

function Foo({ isOpen }: { isOpen?: boolean }) {
useControlledState({ isOpen });
function Foo({ isExpanded }: { isExpanded?: boolean }) {
useControlledState({ isExpanded });
return <div />;
}

it('warns about changing from uncontrolled to controlled', () => {
const { rerender } = render(<Foo />);
rerender(<Foo isOpen />);
rerender(<Foo isExpanded />);

expect(consoleOutput[0]).toMatchInlineSnapshot(
`"Warning: useCollapse is changing from uncontrolled to controlled. useCollapse should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the \`isOpen\` prop."`
`"Warning: useCollapse is changing from uncontrolled to controlled. useCollapse should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the \`isExpanded\` prop."`
);
expect(consoleOutput.length).toBe(1);
});

it('warns about changing from controlled to uncontrolled', () => {
// Initially control the value
const { rerender } = render(<Foo isOpen={true} />);
const { rerender } = render(<Foo isExpanded={true} />);
// Then re-render without controlling it
rerender(<Foo isOpen={undefined} />);
rerender(<Foo isExpanded={undefined} />);

expect(consoleOutput[0]).toMatchInlineSnapshot(
`"Warning: useCollapse is changing from controlled to uncontrolled. useCollapse should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the \`isOpen\` prop."`
`"Warning: useCollapse is changing from controlled to uncontrolled. useCollapse should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the \`isExpanded\` prop."`
);
expect(consoleOutput.length).toBe(1);
});
Expand Down
44 changes: 28 additions & 16 deletions src/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import { mocked } from 'ts-jest/utils';
import useCollapse from '../';
import { getElementHeight } from '../utils';
import {
GetTogglePropsShape,
GetCollapsePropsShape,
CollapseConfig,
GetTogglePropsInput,
GetCollapsePropsInput,
UseCollapseInput,
} from '../types';

const mockedGetElementHeight = mocked(getElementHeight, true);

const Collapse: React.FC<{
toggleProps?: GetTogglePropsShape;
collapseProps?: GetCollapsePropsShape;
props?: CollapseConfig;
toggleProps?: GetTogglePropsInput;
collapseProps?: GetCollapsePropsInput;
props?: UseCollapseInput;
unmountChildren?: boolean;
}> = ({ toggleProps, collapseProps, props, unmountChildren = false }) => {
const { getCollapseProps, getToggleProps, mountChildren } = useCollapse(
Expand Down Expand Up @@ -43,9 +43,9 @@ test('does not throw', () => {
test('returns expected constants', () => {
const { result } = renderHook(useCollapse);

expect(result.current.isOpen).toStrictEqual(false);
expect(result.current.isExpanded).toStrictEqual(false);
expect(result.current.mountChildren).toStrictEqual(false);
expect(typeof result.current.toggleOpen).toBe('function');
expect(typeof result.current.toggleExpanded).toBe('function');
expect(typeof result.current.getToggleProps()).toBe('object');
expect(typeof result.current.getCollapseProps()).toBe('object');
});
Expand All @@ -60,7 +60,9 @@ test('Toggle has expected props when closed (default)', () => {
});

test('Toggle has expected props when collapse is open', () => {
const { getByTestId } = render(<Collapse props={{ defaultOpen: true }} />);
const { getByTestId } = render(
<Collapse props={{ defaultExpanded: true }} />
);
const toggle = getByTestId('toggle');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
});
Expand All @@ -79,7 +81,9 @@ test('Collapse has expected props when closed (default)', () => {
});

test('Collapse has expected props when open', () => {
const { getByTestId } = render(<Collapse props={{ defaultOpen: true }} />);
const { getByTestId } = render(
<Collapse props={{ defaultExpanded: true }} />
);
const collapse = getByTestId('collapse');
expect(collapse).toHaveAttribute('id');
expect(collapse).toHaveAttribute('aria-hidden', 'false');
Expand All @@ -105,7 +109,7 @@ test('Re-render does not modify id', () => {
const collapse = getByTestId('collapse');
const collapseId = collapse.getAttribute('id');

rerender(<Collapse props={{ defaultOpen: true }} />);
rerender(<Collapse props={{ defaultExpanded: true }} />);
expect(collapseId).toEqual(collapse.getAttribute('id'));
});

Expand All @@ -126,7 +130,9 @@ test.skip('clicking the toggle closes the collapse', () => {
// Mocked since ref element sizes = :( in jsdom
mockedGetElementHeight.mockReturnValue(0);

const { getByTestId } = render(<Collapse props={{ defaultOpen: true }} />);
const { getByTestId } = render(
<Collapse props={{ defaultExpanded: true }} />
);
const toggle = getByTestId('toggle');
const collapse = getByTestId('collapse');

Expand All @@ -136,10 +142,10 @@ test.skip('clicking the toggle closes the collapse', () => {
expect(collapse.style.height).toBe('0px');
});

test('toggle click calls onClick argument with isOpen', () => {
test('toggle click calls onClick argument with isExpanded', () => {
const onClick = jest.fn();
const { getByTestId } = render(
<Collapse props={{ defaultOpen: true }} toggleProps={{ onClick }} />
<Collapse props={{ defaultExpanded: true }} toggleProps={{ onClick }} />
);
const toggle = getByTestId('toggle');

Expand All @@ -156,7 +162,7 @@ describe('mountChildren', () => {

it('children rendered when mounted open', () => {
const { queryByText } = render(
<Collapse props={{ defaultOpen: true }} unmountChildren />
<Collapse props={{ defaultExpanded: true }} unmountChildren />
);
expect(queryByText('content')).toBeInTheDocument();
});
Expand All @@ -172,7 +178,7 @@ test('warns if using padding on collapse', () => {

render(
<Collapse
props={{ defaultOpen: true }}
props={{ defaultExpanded: true }}
collapseProps={{ style: { padding: 20 } }}
/>
);
Expand All @@ -183,3 +189,9 @@ test('warns if using padding on collapse', () => {

console.warn = originalWarn;
});

test('permits access to the collapse ref', () => {
let cb = jest.fn();
const { queryByTestId } = render(<Collapse collapseProps={{ ref: cb }} />);
expect(cb).toHaveBeenCalledWith(queryByTestId('collapse'));
});
Loading

0 comments on commit 29aee67

Please sign in to comment.