├── .github └── workflows │ └── release.yml ├── .gitignore ├── .nvmrc ├── .storybook ├── main.js └── preview.js ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MIGRATION-2.x-to-3.x.md ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json └── tsconfig.json ├── package.json ├── release.config.js ├── setupTests.ts ├── src ├── __tests__ │ ├── index.test.tsx │ ├── mocks │ │ └── utils.ts │ └── utils.test.tsx ├── global.d.ts ├── index.ts ├── stories │ ├── basic.stories.tsx │ ├── components.tsx │ ├── div.stories.tsx │ ├── nested.stories.tsx │ └── unmount.stories.tsx ├── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release npm package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: 'CI' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 14 18 | - name: Install Dependencies 19 | uses: bahmutov/npm-install@v1 20 | - name: Build 21 | run: yarn build 22 | - name: Lint 23 | run: yarn lint 24 | - name: Test 25 | run: yarn test 26 | 27 | release: 28 | name: Publish to NPM 29 | needs: test 30 | # publish only when merged in master on original repo, not on PR 31 | if: github.repository == 'roginfarrer/react-collapsed' && github.ref == 'refs/heads/main' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-node@v1 36 | with: 37 | node-version: 14 38 | - name: Install Dependencies 39 | uses: bahmutov/npm-install@v1 40 | - name: Build 41 | run: yarn build 42 | - run: npx semantic-release 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | storybook-static 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.18.0 2 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-a11y" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.0 2 | 3 | Complete rewrite using React hooks! 4 | 5 | - Ends support for React versions < 16.8.x 6 | - Library now exports a custom hook in lieu of a render prop component 7 | - Adds support for unmounting the contents of the Collapse element when closed 8 | 9 | ```js 10 | import React from 'react' 11 | import useCollapse from 'react-collapsed' 12 | 13 | function Demo() { 14 | const { getCollapseProps, getToggleProps, isOpen } = useCollapse() 15 | 16 | return ( 17 | <> 18 | 19 |
Collapsed content 🙈
20 | 21 | ) 22 | } 23 | ``` 24 | 25 | # 1.0.0 26 | 27 | Bumped to full release! :) 28 | 29 | - `duration`, `easing`, and `delay` now support taking an object with `in` and `out` keys to configure differing in-and-out transitions 30 | 31 | # 0.2.0 32 | 33 | ### Breaking Changes 34 | 35 | - `getCollapsibleProps` => `getCollapseProps`. Renamed since it's easier to spell 😅 36 | 37 | ### Other 38 | 39 | - Slew of Flow bug fixes 40 | - Improved documentation 41 | 42 | # 0.1.3 43 | 44 | - ESLINT wasn't working properly - fixed this 45 | - Added `files` key to package.json to improve NPM load 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for wanting to make this component better! 4 | 5 | ### Project setup 6 | 7 | 1. Fork and clone the repo 8 | 2. `yarn install` and `yarn dev` to install dependencies and spin up the demo site locally 9 | 3. Create a branch for your PR 10 | 11 | **Tip:** Keep your main branch pointing at the original repository and make pull requests from branches on your fork. To do this, run: 12 | 13 | ```bash 14 | git remote add upstream https://github.com/roginfarrer/react-collapsed.git 15 | git fetch upstream 16 | git branch --set-upstream-to=upstream/main main 17 | ``` 18 | 19 | This will add the original repository as a "remote" called "upstream," Then fetch the git information from that remote, then set your local main branch to use the upstream main branch whenever you run git pull. Then you can make all of your pull request branches based on this main branch. Whenever you want to update your version of main, do a regular git pull. 20 | 21 | ### Committing and Pushing changes 22 | 23 | Please make sure to run the tests before you commit your changes. You can run `yarn test` to run them (or `yarn test:watch`). Make sure to add new tests for any new features or changes. All tests must pass for a pull request to be accepted. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2020 Rogin Farrer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIGRATION-2.x-to-3.x.md: -------------------------------------------------------------------------------- 1 | # Migrating from 2.x to 3.x 2 | 3 | ## BREAKING CHANGES 4 | 5 | - `useCollapse` has been completely rewritten in TypeScript, and now exports types. 6 | - `useCollapse` configuration has changed: 7 | - `isOpen` -> `isExpanded` 8 | - `defaultOpen` -> `defaultExpanded` 9 | - `expandStyles.transitionDuration` and `collapseStyles.transitionDuration` have been moved to a single `duration` property 10 | - `expandStyles.transitionTimingFunction` and `collapseStyles.transitionTimingFunction` have been moved to a single `easing` property 11 | - `useCollapse` output has changed: 12 | - `isOpen` -> `isExpanded` 13 | - `mountChildren` has been removed. Event hooks are now provided to recreate this feature. [See below for more](#mountChildren) 14 | - `toggleOpen` has been replaced with `setExpanded`, which requires a boolean that sets the expanded state, or a callback that returns a boolean. 15 | - The default transition duration has been changed from `500ms` to being calculated based on the height of the collapsed content. Encouraged to leave this default since it will provide more natural animations. 16 | - The default transition curve has been changed from `cubic-bezier(0.250, 0.460, 0.450, 0.940)` to `ease-in-out`, or `cubic-bezier(0.4, 0, 0.2, 1)` 17 | 18 | See below for more detail on the above changes. 19 | 20 | ## Input 21 | 22 | The hook's property names have been changed for clarity: 23 | 24 | - `isOpen` -> `isExpanded` 25 | - `defaultOpen` -> `defaultExpanded` 26 | 27 | In 2.x, the customizing the transition duration and easing was done by setting `transitionDuration` and `transitionTimingFunction` in `expandStyles` or `collapseStyles`. Those have been both pulled out and promoted to top-level settings via `duration` and `easing`, respectively. 28 | 29 | The default value for `duration` is also no longer a fixed value. Instead, the duration is calculated based on the height of the collapsed content to create more natural transitions. 30 | 31 | The transition easing was also updated from a custom curve to a more basic `ease-in-out` curve. 32 | 33 | In summary: 34 | 35 | ```diff 36 | const collapse = useCollaspse({ 37 | collapseStyles: {}, 38 | expandStyles: {}, 39 | collapsedHeight: number, 40 | - isOpen: boolean, 41 | - defaultOpen: boolean, 42 | + duration: number, 43 | + easing: string, 44 | + isExpanded: boolean, 45 | + defaultExpanded: boolean, 46 | + onCollapseStart() {}, 47 | + onCollapseEnd() {}, 48 | + onExpandStart() {}, 49 | + onExpandEnd() {}, 50 | }) 51 | ``` 52 | 53 | ## Output 54 | 55 | - `isOpen` -> `isExpanded` 56 | - `toggleOpen` -> `setExpanded` 57 | - `mountChildren` has been removed. 58 | 59 | `setExpanded` now also supports an argument to set the expanded state. Previously, to toggle the expanded state, you would just call the `toggleOpen` function: 60 | 61 | ```javascript 62 | 63 | ``` 64 | 65 | Now, you must provide a boolean or a function that returns a boolean: 66 | 67 | ```javascript 68 | 71 | ``` 72 | 73 | ### `mountChildren` 74 | 75 | `mountChildren` has been removed. In order to recreate the same functionality, you can hook into the `onExpandStart` and `onCollapseEnd` hooks: 76 | 77 | ```javascript 78 | function Collapse() { 79 | const [mountChildren, setMountChildren] = useState(false) 80 | const { getToggleProps, getCollapseProps } = useCollapse({ 81 | onCollapseEnd() { 82 | setMountChildren(false) 83 | }, 84 | onExpandStart() { 85 | setMountChildren(true) 86 | }, 87 | }) 88 | 89 | return ( 90 |
91 | 92 |
93 | {mountChildren &&

I will only render when expanded!

} 94 |
95 |
96 | ) 97 | } 98 | ``` 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-collapsed (useCollapse) 2 | 3 | [![CI][ci-badge]][ci] 4 | ![npm bundle size (version)][minzipped-badge] 5 | [![npm version][npm-badge]][npm-version] 6 | [![Netlify Status](https://api.netlify.com/api/v1/badges/5a5b0e80-d15e-4983-976d-37fe6bdada7a/deploy-status)](https://app.netlify.com/sites/react-collapsed/deploys) 7 | 8 | A custom hook for creating accessible expand/collapse components in React. Animates the height using CSS transitions from `0` to `auto`. 9 | 10 | ## Features 11 | 12 | - Handles the height of animations of your elements, `auto` included! 13 | - You control the UI - `useCollapse` provides the necessary props, you control the styles and the elements. 14 | - Accessible out of the box - no need to worry if your collapse/expand component is accessible, since this takes care of it for you! 15 | - No animation framework required! Simply powered by CSS animations 16 | - Written in TypeScript 17 | 18 | ## Demo 19 | 20 | [See the demo site!](https://react-collapsed.netlify.app/) 21 | 22 | [CodeSandbox demo](https://codesandbox.io/s/magical-browser-vibv2?file=/src/App.tsx) 23 | 24 | ## Installation 25 | 26 | ```bash 27 | $ yarn add react-collapsed 28 | # or 29 | $ npm i react-collapsed 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Simple Usage 35 | 36 | ```js 37 | import React from 'react' 38 | import useCollapse from 'react-collapsed' 39 | 40 | function Demo() { 41 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse() 42 | 43 | return ( 44 |
45 | 48 |
Collapsed content 🙈
49 |
50 | ) 51 | } 52 | ``` 53 | 54 | ### Control it yourself 55 | 56 | ```js 57 | import React, { useState } from 'react' 58 | import useCollapse from 'react-collapsed' 59 | 60 | function Demo() { 61 | const [isExpanded, setExpanded] = useState(false) 62 | const { getCollapseProps, getToggleProps } = useCollapse({ isExpanded }) 63 | 64 | return ( 65 |
66 | 73 |
Collapsed content 🙈
74 |
75 | ) 76 | } 77 | ``` 78 | 79 | ## API 80 | 81 | ```js 82 | const { getCollapseProps, getToggleProps, isExpanded, setExpanded } = 83 | useCollapse({ 84 | isExpanded: boolean, 85 | defaultExpanded: boolean, 86 | expandStyles: {}, 87 | collapseStyles: {}, 88 | collapsedHeight: 0, 89 | easing: string, 90 | duration: number, 91 | onCollapseStart: func, 92 | onCollapseEnd: func, 93 | onExpandStart: func, 94 | onExpandEnd: func, 95 | }) 96 | ``` 97 | 98 | ### `useCollapse` Config 99 | 100 | The following are optional properties passed into `useCollapse({ })`: 101 | 102 | | Prop | Type | Default | Description | 103 | | -------------------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | 104 | | isExpanded | boolean | `undefined` | If true, the Collapse is expanded | 105 | | defaultExpanded | boolean | `false` | If true, the Collapse will be expanded when mounted | 106 | | expandStyles | object | `{}` | Style object applied to the collapse panel when it expands | 107 | | collapseStyles | object | `{}` | Style object applied to the collapse panel when it collapses | 108 | | collapsedHeight | number | `0` | The height of the content when collapsed | 109 | | easing | string | `cubic-bezier(0.4, 0, 0.2, 1)` | The transition timing function for the animation | 110 | | 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 | 111 | | onCollapseStart | function | no-op | Handler called when the collapse animation begins | 112 | | onCollapseEnd | function | no-op | Handler called when the collapse animation ends | 113 | | onExpandStart | function | no-op | Handler called when the expand animation begins | 114 | | onExpandEnd | function | no-op | Handler called when the expand animation ends | 115 | | hasDisabledAnimation | boolean | false | If true, will disable the animation | 116 | 117 | ### What you get 118 | 119 | | Name | Description | 120 | | ---------------- | ----------------------------------------------------------------------------------------------------------- | 121 | | getCollapseProps | Function that returns a prop object, which should be spread onto the collapse element | 122 | | getToggleProps | Function that returns a prop object, which should be spread onto an element that toggles the collapse panel | 123 | | isExpanded | Whether or not the collapse is expanded (if not controlled) | 124 | | setExpanded | Sets the hook's internal isExpanded state | 125 | 126 | ## Alternative Solutions 127 | 128 | - [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. 129 | - [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. 130 | 131 | ## FAQ 132 | 133 |
134 | When I apply vertical padding to the component that gets getCollapseProps, the animation is janky and it doesn't collapse all the way. What gives? 135 | 136 | 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`. 137 | 138 | To avoid this, simply move that padding from the element to an element directly nested within in. 139 | 140 | ```javascript 141 | // from 142 |
145 | 146 | // to 147 |
149 | Much better! 150 |
151 |
152 | ``` 153 | 154 |
155 | 156 | [minzipped-badge]: https://img.shields.io/bundlephobia/minzip/react-collapsed/latest 157 | [npm-badge]: http://img.shields.io/npm/v/react-collapsed.svg?style=flat 158 | [npm-version]: https://npmjs.org/package/react-collapsed 'View this project on npm' 159 | [ci-badge]: https://github.com/roginfarrer/react-collapsed/workflows/CI/badge.svg 160 | [ci]: https://github.com/roginfarrer/react-collapsed/actions?query=workflow%3ACI+branch%3Amain 161 | [netlify]: https://app.netlify.com/sites/react-collapsed/deploys 162 | [netlify-badge]: https://api.netlify.com/api/v1/badges/4d285ffc-aa4f-4d32-8549-eb58e00dd2d1/deploy-status 163 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11' 2 | import * as React from 'react' 3 | import * as ReactDOM from 'react-dom' 4 | import useCollapse from '../src' 5 | 6 | const collapseStyles = { background: 'blue', color: 'white' } 7 | 8 | export const Uncontrolled = () => { 9 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({ 10 | defaultExpanded: true, 11 | }) 12 | 13 | return ( 14 |
15 | 18 |
23 | In the morning I walked down the Boulevard to the rue Soufflot for 24 | coffee and brioche. It was a fine morning. The horse-chestnut trees in 25 | the Luxembourg gardens were in bloom. There was the pleasant 26 | early-morning feeling of a hot day. I read the papers with the coffee 27 | and then smoked a cigarette. The flower-women were coming up from the 28 | market and arranging their daily stock. Students went by going up to the 29 | law school, or down to the Sorbonne. The Boulevard was busy with trams 30 | and people going to work. 31 |
32 |
33 | ) 34 | } 35 | 36 | const App = () => { 37 | return ( 38 |
39 | 40 |
41 | ) 42 | } 43 | 44 | ReactDOM.render(, document.getElementById('root')) 45 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-collapsed", 3 | "version": "0.0.0-development", 4 | "author": "Rogin Farrer ", 5 | "description": "A tiny React custom-hook for creating flexible and accessible expand/collapse components.", 6 | "license": "MIT", 7 | "source": "src/index.ts", 8 | "main": "dist/react-collapsed.js", 9 | "module": "dist/react-collapsed.esm.js", 10 | "umd:main": "dist/react-collapsed.umd.js", 11 | "unpkg": "dist/react-collapsed.umd.js", 12 | "types": "dist/index.d.ts", 13 | "files": [ 14 | "src", 15 | "dist" 16 | ], 17 | "scripts": { 18 | "watch": "microbundle watch", 19 | "build": "microbundle", 20 | "test": "jest src", 21 | "lint": "tsc --project tsconfig.json --noEmit", 22 | "format": "prettier --write **/*.{js,ts,tsx,yml,md,md,json}", 23 | "storybook": "start-storybook -p 6006", 24 | "build-storybook": "build-storybook", 25 | "release": "np --no-2fa" 26 | }, 27 | "peerDependencies": { 28 | "react": ">=18", 29 | "react-dom": ">=18" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.8.4", 33 | "@babel/eslint-parser": "^7.15.4", 34 | "@babel/preset-react": "^7.14.5", 35 | "@storybook/addon-a11y": "^6.3.9", 36 | "@storybook/addon-actions": "^6.3.9", 37 | "@storybook/addon-essentials": "^6.3.9", 38 | "@storybook/addon-links": "^6.3.9", 39 | "@storybook/react": "^6.3.9", 40 | "@testing-library/jest-dom": "^5.3.0", 41 | "@testing-library/react": "^10.0.2", 42 | "@types/jest": "^25.1.2", 43 | "@types/node": "^16.7.13", 44 | "@types/raf": "^3.4.0", 45 | "@types/react": "^17.0.3", 46 | "@types/react-dom": "^17.0.3", 47 | "@types/styled-components": "^5.0.1", 48 | "@typescript-eslint/eslint-plugin": "^4.31.0", 49 | "@typescript-eslint/parser": "^4.31.0", 50 | "babel-loader": "^8.2.2", 51 | "eslint": "^7.32.0", 52 | "eslint-config-airbnb": "^18.2.1", 53 | "eslint-config-airbnb-typescript": "^14.0.0", 54 | "eslint-config-prettier": "^8.3.0", 55 | "eslint-config-rogin": "1.0.0", 56 | "eslint-plugin-html": "^6.1.2", 57 | "eslint-plugin-import": "^2.24.2", 58 | "eslint-plugin-jsx-a11y": "^6.4.1", 59 | "eslint-plugin-prettier": "^4.0.0", 60 | "eslint-plugin-react": "^7.25.1", 61 | "eslint-plugin-react-hooks": "^4.2.0", 62 | "jest": "^27.1.0", 63 | "microbundle": "^0.13.3", 64 | "np": "^6.4.0", 65 | "prettier": "^2.3.2", 66 | "react": "next", 67 | "react-docgen-typescript-loader": "^3.7.1", 68 | "react-dom": "next", 69 | "semantic-release": "^18.0.0", 70 | "styled-components": "^5.2.0", 71 | "ts-jest": "^27.0.5", 72 | "typescript": "^4.4.2" 73 | }, 74 | "dependencies": { 75 | "raf": "^3.4.1", 76 | "tiny-warning": "^1.0.3" 77 | }, 78 | "jest": { 79 | "preset": "ts-jest", 80 | "testEnvironment": "jsdom", 81 | "setupFilesAfterEnv": [ 82 | "/setupTests.ts" 83 | ], 84 | "globals": { 85 | "__DEV__": true 86 | }, 87 | "testMatch": [ 88 | "/**/*.(spec|test).{ts,tsx,js,jsx}" 89 | ] 90 | }, 91 | "repository": { 92 | "type": "git", 93 | "url": "https://github.com/roginfarrer/react-collapsed.git" 94 | }, 95 | "bugs": { 96 | "url": "https://github.com/roginfarrer/react-collapsed/issues" 97 | }, 98 | "keywords": [ 99 | "collapse", 100 | "react", 101 | "collapsible", 102 | "animate", 103 | "height", 104 | "render", 105 | "expand", 106 | "hooks", 107 | "auto" 108 | ], 109 | "engines": { 110 | "node": ">=12" 111 | }, 112 | "prettier": "eslint-config-rogin/prettier" 113 | } 114 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: [ 3 | '+([0-9])?(.{+([0-9]),x}).x', 4 | 'main', 5 | 'next', 6 | 'next-major', 7 | { name: 'beta', prerelease: true }, 8 | { name: 'alpha', prerelease: true }, 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import { mocked } from 'ts-jest/utils' 4 | import useCollapse from '..' 5 | import { getElementHeight } from '../utils' 6 | import { 7 | GetTogglePropsInput, 8 | GetCollapsePropsInput, 9 | UseCollapseInput, 10 | } from '../types' 11 | 12 | const mockedGetElementHeight = mocked(getElementHeight, true) 13 | 14 | const Collapse: React.FC<{ 15 | toggleProps?: GetTogglePropsInput 16 | collapseProps?: GetCollapsePropsInput 17 | props?: UseCollapseInput 18 | }> = ({ toggleProps, collapseProps, props }) => { 19 | const { getCollapseProps, getToggleProps } = useCollapse(props) 20 | return ( 21 | <> 22 |
23 | Toggle 24 |
25 |
26 |
content
27 |
28 | 29 | ) 30 | } 31 | 32 | test('does not throw', () => { 33 | const result = () => render() 34 | expect(result).not.toThrow() 35 | }) 36 | 37 | test('Toggle has expected props when closed (default)', () => { 38 | const { getByTestId } = render() 39 | const toggle = getByTestId('toggle') 40 | expect(toggle).toHaveAttribute('type', 'button') 41 | expect(toggle).toHaveAttribute('role', 'button') 42 | expect(toggle).toHaveAttribute('tabIndex', '0') 43 | expect(toggle).toHaveAttribute('aria-expanded', 'false') 44 | }) 45 | 46 | test('Toggle has expected props when collapse is open', () => { 47 | const { getByTestId } = render() 48 | const toggle = getByTestId('toggle') 49 | expect(toggle.getAttribute('aria-expanded')).toBe('true') 50 | }) 51 | 52 | test('Collapse has expected props when closed (default)', () => { 53 | const { getByTestId } = render() 54 | const collapse = getByTestId('collapse') 55 | expect(collapse).toHaveAttribute('id') 56 | expect(collapse.getAttribute('aria-hidden')).toBe('true') 57 | expect(collapse.style).toEqual( 58 | expect.objectContaining({ 59 | display: 'none', 60 | height: '0px', 61 | }) 62 | ) 63 | }) 64 | 65 | test('Collapse has expected props when open', () => { 66 | const { getByTestId } = render() 67 | const collapse = getByTestId('collapse') 68 | expect(collapse).toHaveAttribute('id') 69 | expect(collapse).toHaveAttribute('aria-hidden', 'false') 70 | expect(collapse.style).not.toContain( 71 | expect.objectContaining({ 72 | display: 'none', 73 | height: '0px', 74 | }) 75 | ) 76 | }) 77 | 78 | test("Toggle's aria-controls matches Collapse's id", () => { 79 | const { getByTestId } = render() 80 | const toggle = getByTestId('toggle') 81 | const collapse = getByTestId('collapse') 82 | expect(toggle.getAttribute('aria-controls')).toEqual( 83 | collapse.getAttribute('id') 84 | ) 85 | }) 86 | 87 | test('Re-render does not modify id', () => { 88 | const { getByTestId, rerender } = render() 89 | const collapse = getByTestId('collapse') 90 | const collapseId = collapse.getAttribute('id') 91 | 92 | rerender() 93 | expect(collapseId).toEqual(collapse.getAttribute('id')) 94 | }) 95 | 96 | test.skip('clicking the toggle expands the collapse', () => { 97 | // Mocked since ref element sizes = :( in jsdom 98 | mockedGetElementHeight.mockReturnValue(400) 99 | 100 | const { getByTestId } = render() 101 | const toggle = getByTestId('toggle') 102 | const collapse = getByTestId('collapse') 103 | 104 | expect(collapse.style.height).toBe('0px') 105 | fireEvent.click(toggle) 106 | expect(collapse.style.height).toBe('400px') 107 | }) 108 | 109 | test.skip('clicking the toggle closes the collapse', () => { 110 | // Mocked since ref element sizes = :( in jsdom 111 | mockedGetElementHeight.mockReturnValue(0) 112 | 113 | const { getByTestId } = render() 114 | const toggle = getByTestId('toggle') 115 | const collapse = getByTestId('collapse') 116 | 117 | // No defined height when open 118 | expect(collapse.style.height).toBe('') 119 | fireEvent.click(toggle) 120 | expect(collapse.style.height).toBe('0px') 121 | }) 122 | 123 | test('toggle click calls onClick argument with isExpanded', () => { 124 | const onClick = jest.fn() 125 | const { getByTestId } = render( 126 | 127 | ) 128 | const toggle = getByTestId('toggle') 129 | 130 | fireEvent.click(toggle) 131 | expect(onClick).toHaveBeenCalled() 132 | }) 133 | 134 | test('warns if using padding on collapse', () => { 135 | // Mocking console.warn so it does not log to the console, 136 | // but we can still intercept the message 137 | const originalWarn = console.warn 138 | let consoleOutput = '' 139 | const mockWarn = (output: any) => (consoleOutput = output) 140 | console.warn = jest.fn(mockWarn) 141 | 142 | render( 143 | 147 | ) 148 | 149 | expect(consoleOutput).toMatchInlineSnapshot( 150 | `"Warning: react-collapsed: Padding applied to the collapse element will cause the animation to break and not perform as expected. To fix, apply equivalent padding to the direct descendent of the collapse element."` 151 | ) 152 | 153 | console.warn = originalWarn 154 | }) 155 | 156 | test('permits access to the collapse ref', () => { 157 | const cb = jest.fn() 158 | const { queryByTestId } = render() 159 | expect(cb).toHaveBeenCalledWith(queryByTestId('collapse')) 160 | }) 161 | -------------------------------------------------------------------------------- /src/__tests__/mocks/utils.ts: -------------------------------------------------------------------------------- 1 | const actualUtils = require('../../utils') 2 | 3 | Object.defineProperty(window, 'getComputedStyle', { 4 | value: jest.fn(), 5 | }) 6 | 7 | module.exports = Object.assign(actualUtils, { 8 | // eslint-disable-next-line no-undef 9 | getElementHeight: jest.fn(), 10 | }) 11 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, act } from '@testing-library/react' 3 | import { 4 | useEffectAfterMount, 5 | useControlledState, 6 | callAll, 7 | } from '../utils' 8 | 9 | describe('callAll', () => { 10 | it('it calls the two functions passed into it', () => { 11 | const functionOne = jest.fn() 12 | const functionTwo = jest.fn() 13 | const theFunk = callAll(functionOne, functionTwo) 14 | theFunk() 15 | expect(functionOne).toHaveBeenCalled() 16 | expect(functionTwo).toHaveBeenCalled() 17 | }) 18 | }) 19 | 20 | describe('useEffectAfterMount', () => { 21 | it('does not run callback on first render', () => { 22 | // Provide a dependency that changes, so it re-renders 23 | let x = 0 24 | const cb = jest.fn() 25 | 26 | function UseEffectAfterMount() { 27 | x++ 28 | useEffectAfterMount(cb, [x]) 29 | return null 30 | } 31 | 32 | const { rerender } = render() 33 | 34 | expect(cb).not.toHaveBeenCalled() 35 | rerender() 36 | expect(cb).toHaveBeenCalled() 37 | }) 38 | }) 39 | 40 | describe('useControlledState', () => { 41 | let hookReturn: [boolean, React.Dispatch>] 42 | 43 | function UseControlledState({ 44 | defaultExpanded, 45 | isExpanded, 46 | }: { 47 | defaultExpanded?: boolean 48 | isExpanded?: boolean 49 | }) { 50 | const result = useControlledState(isExpanded, defaultExpanded) 51 | 52 | hookReturn = result 53 | 54 | return null 55 | } 56 | 57 | it('returns a boolean and a function', () => { 58 | render() 59 | 60 | expect(hookReturn[0]).toBe(false) 61 | expect(typeof hookReturn[1]).toBe('function') 62 | }) 63 | 64 | it('returns the defaultValue value', () => { 65 | render() 66 | 67 | expect(hookReturn[0]).toBe(true) 68 | }) 69 | 70 | it('setter toggles the value', () => { 71 | render() 72 | 73 | expect(hookReturn[0]).toBe(true) 74 | 75 | act(() => { 76 | hookReturn[1]((n) => !n) 77 | }) 78 | 79 | expect(hookReturn[0]).toBe(false) 80 | }) 81 | 82 | describe('dev feedback', () => { 83 | // Mocking console.warn so it does not log to the console, 84 | // but we can still intercept the message 85 | const originalWarn = console.warn 86 | let consoleOutput: string[] = [] 87 | const mockWarn = (output: any) => consoleOutput.push(output) 88 | 89 | beforeEach(() => (console.warn = mockWarn)) 90 | afterEach(() => { 91 | console.warn = originalWarn 92 | consoleOutput = [] 93 | }) 94 | 95 | function Foo({ isExpanded }: { isExpanded?: boolean }) { 96 | useControlledState(isExpanded) 97 | return
98 | } 99 | 100 | it('warns about changing from uncontrolled to controlled', () => { 101 | const { rerender } = render() 102 | rerender() 103 | 104 | expect(consoleOutput[0]).toMatchInlineSnapshot( 105 | `"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."` 106 | ) 107 | expect(consoleOutput.length).toBe(1) 108 | }) 109 | 110 | it('warns about changing from controlled to uncontrolled', () => { 111 | // Initially control the value 112 | const { rerender } = render() 113 | // Then re-render without controlling it 114 | rerender() 115 | 116 | expect(consoleOutput[0]).toMatchInlineSnapshot( 117 | `"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."` 118 | ) 119 | expect(consoleOutput.length).toBe(1) 120 | }) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { useState, useId, useRef, TransitionEvent, CSSProperties } from 'react' 3 | import { flushSync } from 'react-dom' 4 | import raf from 'raf' 5 | import { 6 | noop, 7 | callAll, 8 | getElementHeight, 9 | getAutoHeightDuration, 10 | mergeRefs, 11 | usePaddingWarning, 12 | useEffectAfterMount, 13 | useControlledState, 14 | } from './utils' 15 | import { 16 | UseCollapseInput, 17 | UseCollapseOutput, 18 | GetCollapsePropsOutput, 19 | GetCollapsePropsInput, 20 | GetTogglePropsOutput, 21 | GetTogglePropsInput, 22 | } from './types' 23 | 24 | const easeInOut = 'cubic-bezier(0.4, 0, 0.2, 1)' 25 | 26 | export default function useCollapse({ 27 | duration, 28 | easing = easeInOut, 29 | collapseStyles = {}, 30 | expandStyles = {}, 31 | onExpandStart = noop, 32 | onExpandEnd = noop, 33 | onCollapseStart = noop, 34 | onCollapseEnd = noop, 35 | isExpanded: configIsExpanded, 36 | defaultExpanded = false, 37 | hasDisabledAnimation = false, 38 | ...initialConfig 39 | }: UseCollapseInput = {}): UseCollapseOutput { 40 | const [isExpanded, setExpanded] = useControlledState( 41 | configIsExpanded, 42 | defaultExpanded 43 | ) 44 | // @ts-ignore 45 | const uniqueId = useId() 46 | const el = useRef(null) 47 | usePaddingWarning(el) 48 | const collapsedHeight = `${initialConfig.collapsedHeight || 0}px` 49 | const collapsedStyles = { 50 | display: collapsedHeight === '0px' ? 'none' : 'block', 51 | height: collapsedHeight, 52 | overflow: 'hidden', 53 | } 54 | const [styles, setStylesRaw] = useState( 55 | isExpanded ? {} : collapsedStyles 56 | ) 57 | const setStyles = (newStyles: {} | ((oldStyles: {}) => {})): void => { 58 | // We rely on reading information from layout 59 | // at arbitrary times, so ensure all style changes 60 | // happen before we might attempt to read them. 61 | flushSync(() => { 62 | setStylesRaw(newStyles) 63 | }) 64 | } 65 | const mergeStyles = (newStyles: {}): void => { 66 | setStyles((oldStyles) => ({ ...oldStyles, ...newStyles })) 67 | } 68 | 69 | function getTransitionStyles(height: number | string): CSSProperties { 70 | if (hasDisabledAnimation) { 71 | return {} 72 | } 73 | const _duration = duration || getAutoHeightDuration(height) 74 | return { 75 | transition: `height ${_duration}ms ${easing}`, 76 | } 77 | } 78 | 79 | useEffectAfterMount(() => { 80 | if (isExpanded) { 81 | raf(() => { 82 | onExpandStart() 83 | mergeStyles({ 84 | ...expandStyles, 85 | willChange: 'height', 86 | display: 'block', 87 | overflow: 'hidden', 88 | }) 89 | raf(() => { 90 | const height = getElementHeight(el) 91 | mergeStyles({ 92 | ...getTransitionStyles(height), 93 | height, 94 | }) 95 | }) 96 | }) 97 | } else { 98 | raf(() => { 99 | onCollapseStart() 100 | const height = getElementHeight(el) 101 | mergeStyles({ 102 | ...collapseStyles, 103 | ...getTransitionStyles(height), 104 | willChange: 'height', 105 | height, 106 | }) 107 | raf(() => { 108 | mergeStyles({ 109 | height: collapsedHeight, 110 | overflow: 'hidden', 111 | }) 112 | }) 113 | }) 114 | } 115 | }, [isExpanded]) 116 | 117 | const handleTransitionEnd = (e: TransitionEvent): void => { 118 | // Sometimes onTransitionEnd is triggered by another transition, 119 | // such as a nested collapse panel transitioning. But we only 120 | // want to handle this if this component's element is transitioning 121 | if (e.target !== el.current || e.propertyName !== 'height') { 122 | return 123 | } 124 | 125 | // The height comparisons below are a final check before 126 | // completing the transition 127 | // Sometimes this callback is run even though we've already begun 128 | // transitioning the other direction 129 | // The conditions give us the opportunity to bail out, 130 | // which will prevent the collapsed content from flashing on the screen 131 | if (isExpanded) { 132 | const height = getElementHeight(el) 133 | 134 | // If the height at the end of the transition 135 | // matches the height we're animating to, 136 | if (height === styles.height) { 137 | setStyles({}) 138 | } else { 139 | // If the heights don't match, this could be due the height 140 | // of the content changing mid-transition 141 | mergeStyles({ height }) 142 | } 143 | 144 | onExpandEnd() 145 | 146 | // If the height we should be animating to matches the collapsed height, 147 | // it's safe to apply the collapsed overrides 148 | } else if (styles.height === collapsedHeight) { 149 | setStyles(collapsedStyles) 150 | onCollapseEnd() 151 | } 152 | } 153 | 154 | function getToggleProps({ 155 | disabled = false, 156 | onClick = noop, 157 | ...rest 158 | }: GetTogglePropsInput = {}): GetTogglePropsOutput { 159 | return { 160 | type: 'button', 161 | role: 'button', 162 | id: `react-collapsed-toggle-${uniqueId}`, 163 | 'aria-controls': `react-collapsed-panel-${uniqueId}`, 164 | 'aria-expanded': isExpanded, 165 | tabIndex: 0, 166 | disabled, 167 | ...rest, 168 | onClick: disabled ? noop : callAll(onClick, () => setExpanded((n) => !n)), 169 | } 170 | } 171 | 172 | function getCollapseProps({ 173 | style = {}, 174 | onTransitionEnd = noop, 175 | refKey = 'ref', 176 | ...rest 177 | }: GetCollapsePropsInput = {}): GetCollapsePropsOutput { 178 | const theirRef: any = rest[refKey] 179 | return { 180 | id: `react-collapsed-panel-${uniqueId}`, 181 | 'aria-hidden': !isExpanded, 182 | ...rest, 183 | [refKey]: mergeRefs(el, theirRef), 184 | onTransitionEnd: callAll(handleTransitionEnd, onTransitionEnd), 185 | style: { 186 | boxSizing: 'border-box', 187 | // additional styles passed, e.g. getCollapseProps({style: {}}) 188 | ...style, 189 | // style overrides from state 190 | ...styles, 191 | }, 192 | } 193 | } 194 | 195 | return { 196 | getToggleProps, 197 | getCollapseProps, 198 | isExpanded, 199 | setExpanded, 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/stories/basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useCollapse from '..' 3 | import { Toggle, Collapse, excerpt } from './components' 4 | 5 | export const Uncontrolled = () => { 6 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({ 7 | defaultExpanded: true, 8 | }) 9 | 10 | return ( 11 |
12 | {isExpanded ? 'Close' : 'Open'} 13 | {excerpt} 14 |
15 | ) 16 | } 17 | 18 | export const Controlled = () => { 19 | const [isExpanded, setOpen] = React.useState(true) 20 | const { getCollapseProps, getToggleProps } = useCollapse({ 21 | isExpanded, 22 | }) 23 | 24 | return ( 25 |
26 | setOpen((old) => !old) })}> 27 | {isExpanded ? 'Close' : 'Open'} 28 | 29 | {excerpt} 30 |
31 | ) 32 | } 33 | 34 | function useReduceMotion() { 35 | const [matches, setMatch] = React.useState( 36 | window.matchMedia('(prefers-reduced-motion: reduce)').matches 37 | ); 38 | React.useEffect(() => { 39 | const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); 40 | const handleChange = () => { 41 | setMatch(mq.matches); 42 | }; 43 | handleChange(); 44 | mq.addEventListener('change', handleChange); 45 | return () => { 46 | mq.removeEventListener('change', handleChange); 47 | }; 48 | }, []); 49 | return matches; 50 | } 51 | 52 | export const PrefersReducedMotion = () => { 53 | const reduceMotion = useReduceMotion() 54 | const [isExpanded, setOpen] = React.useState(true) 55 | const { getCollapseProps, getToggleProps } = useCollapse({ 56 | isExpanded, 57 | hasDisabledAnimation: reduceMotion, 58 | }) 59 | 60 | return ( 61 |
62 | setOpen((old) => !old) })}> 63 | {isExpanded ? 'Close' : 'Open'} 64 | 65 | {excerpt} 66 |
67 | ) 68 | } 69 | 70 | export default { 71 | title: 'Basic Usage', 72 | } 73 | -------------------------------------------------------------------------------- /src/stories/components.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export const Toggle = styled.button` 5 | box-sizing: border-box; 6 | background: white; 7 | display: inline-block; 8 | text-align: center; 9 | box-shadow: 5px 5px 0 black; 10 | border: 1px solid black; 11 | color: black; 12 | cursor: pointer; 13 | padding: 12px 24px; 14 | font-family: Helvetica; 15 | font-size: 16px; 16 | transition-timing-function: ease; 17 | transition-duration: 150ms; 18 | transition-property: all; 19 | min-width: 150px; 20 | width: 100%; 21 | 22 | @media (min-width: 640px) { 23 | width: auto; 24 | } 25 | 26 | &:hover, 27 | &:focus { 28 | background: rgba(225, 225, 225, 0.8); 29 | } 30 | &:active { 31 | background: black; 32 | color: white; 33 | box-shadow: none; 34 | } 35 | ` 36 | 37 | export const Content = styled.div` 38 | box-sizing: border-box; 39 | border: 2px solid black; 40 | color: #212121; 41 | font-family: Helvetica; 42 | padding: 12px; 43 | font-size: 16px; 44 | line-height: 1.5; 45 | ` 46 | 47 | const CollapseContainer = styled.div` 48 | margin-top: 8px; 49 | ` 50 | 51 | type CollapseProps = { 52 | children: React.ReactNode 53 | style?: {} 54 | } 55 | 56 | export const Collapse = React.forwardRef( 57 | (props: CollapseProps, ref?: React.Ref) => ( 58 | 59 | {props.children} 60 | 61 | ) 62 | ) 63 | 64 | export const excerpt = 65 | 'In the morning I walked down the Boulevard to the rue Soufflot for coffee and brioche. It was a fine morning. The horse-chestnut trees in the Luxembourg gardens were in bloom. There was the pleasant early-morning feeling of a hot day. I read the papers with the coffee and then smoked a cigarette. The flower-women were coming up from the market and arranging their daily stock. Students went by going up to the law school, or down to the Sorbonne. The Boulevard was busy with trams and people going to work.' 66 | -------------------------------------------------------------------------------- /src/stories/div.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useCollapse from '..' 3 | import { Toggle, Collapse, excerpt } from './components' 4 | 5 | export default { 6 | title: 'Using divs', 7 | } 8 | 9 | export const Div = () => { 10 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({ 11 | defaultExpanded: true, 12 | }) 13 | 14 | return ( 15 |
16 | 17 | {isExpanded ? 'Close' : 'Open'} 18 | 19 | {excerpt} 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/stories/nested.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useCollapse from '..' 3 | import { Toggle, Collapse } from './components' 4 | 5 | export default { 6 | title: 'Nested Collapses', 7 | } 8 | 9 | function InnerCollapse() { 10 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse() 11 | 12 | return ( 13 | <> 14 |

15 | Friends, Romans, countrymen, lend me your ears; 16 |
17 | I come to bury Caesar, not to praise him. 18 |
19 | The evil that men do lives after them; 20 |
21 | The good is oft interred with their bones; 22 |
23 | So let it be with Caesar. The noble Brutus 24 |
25 | Hath told you Caesar was ambitious: 26 |
27 | If it were so, it was a grievous fault, 28 |
29 | And grievously hath Caesar answer’d it. 30 |
31 | Here, under leave of Brutus and the rest– 32 |
33 | For Brutus is an honourable man; 34 |
35 | So are they all, all honourable men– 36 |
37 | Come I to speak in Caesar’s funeral. 38 |

39 |

40 | He was my friend, faithful and just to me: 41 |
42 | But Brutus says he was ambitious; 43 |
44 | And Brutus is an honourable man. 45 |
46 | He hath brought many captives home to Rome 47 |
48 | Whose ransoms did the general coffers fill: 49 |
50 | Did this in Caesar seem ambitious? 51 |
52 | When that the poor have cried, Caesar hath wept: 53 |
54 | Ambition should be made of sterner stuff: 55 |

56 | 59 | {isExpanded ? 'Click to collapse' : 'Read more?'} 60 | 61 | 62 | ) 63 | } 64 | 65 | export function Nested() { 66 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({ 67 | defaultExpanded: true, 68 | }) 69 | 70 | return ( 71 | <> 72 | {isExpanded ? 'Close' : 'Expand'} 73 |
74 | 75 | 76 | 77 |
78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /src/stories/unmount.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useCollapse from '..' 3 | import { Collapse, excerpt, Toggle } from './components' 4 | 5 | export default { 6 | title: 'Unmount content on collapse', 7 | component: useCollapse, 8 | } 9 | 10 | export function Unmount() { 11 | const [mountChildren, setMountChildren] = React.useState(true) 12 | const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({ 13 | defaultExpanded: true, 14 | onExpandStart() { 15 | setMountChildren(true) 16 | }, 17 | onCollapseEnd() { 18 | setMountChildren(false) 19 | }, 20 | }) 21 | 22 | return ( 23 | <> 24 | {isExpanded ? 'Close' : 'Open'} 25 |
26 | {mountChildren && {excerpt}} 27 |
28 | 29 | ) 30 | } 31 | 32 | Unmount.story = { 33 | name: 'Unmount content when closed', 34 | } 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ReactNode, 3 | CSSProperties, 4 | TransitionEvent, 5 | MouseEvent, 6 | MutableRefObject, 7 | } from 'react' 8 | 9 | type ButtonType = 'submit' | 'reset' | 'button' 10 | type AriaBoolean = boolean | 'true' | 'false' 11 | 12 | /** 13 | * React.Ref uses the readonly type `React.RefObject` instead of 14 | * `React.MutableRefObject`, We pretty much always assume ref objects are 15 | * mutable (at least when we create them), so this type is a workaround so some 16 | * of the weird mechanics of using refs with TS. 17 | */ 18 | export type AssignableRef = 19 | | { 20 | bivarianceHack(instance: ValueType | null): void 21 | }['bivarianceHack'] 22 | | MutableRefObject 23 | 24 | export interface GetTogglePropsOutput { 25 | disabled: boolean 26 | type: ButtonType 27 | role: string 28 | id: string 29 | 'aria-controls': string 30 | 'aria-expanded': AriaBoolean 31 | tabIndex: number 32 | onClick: (e: MouseEvent) => void 33 | } 34 | 35 | export interface GetTogglePropsInput { 36 | [key: string]: unknown 37 | disabled?: boolean 38 | onClick?: (e: MouseEvent) => void 39 | } 40 | 41 | export interface GetCollapsePropsOutput { 42 | id: string 43 | onTransitionEnd: (e: TransitionEvent) => void 44 | style: CSSProperties 45 | 'aria-hidden': AriaBoolean 46 | } 47 | 48 | export interface GetCollapsePropsInput { 49 | [key: string]: unknown 50 | style?: CSSProperties 51 | onTransitionEnd?: (e: TransitionEvent) => void 52 | refKey?: string 53 | ref?: (node: ReactNode) => void | null | undefined 54 | } 55 | 56 | export interface UseCollapseInput { 57 | isExpanded?: boolean 58 | defaultExpanded?: boolean 59 | collapsedHeight?: number 60 | expandStyles?: {} 61 | collapseStyles?: {} 62 | easing?: string 63 | duration?: number 64 | onCollapseStart?: () => void 65 | onCollapseEnd?: () => void 66 | onExpandStart?: () => void 67 | onExpandEnd?: () => void 68 | hasDisabledAnimation?: boolean 69 | } 70 | 71 | export interface UseCollapseOutput { 72 | getCollapseProps: (config?: GetCollapsePropsInput) => GetCollapsePropsOutput 73 | getToggleProps: (config?: GetTogglePropsInput) => GetTogglePropsOutput 74 | isExpanded: boolean 75 | setExpanded: React.Dispatch> 76 | } 77 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RefObject, 3 | useState, 4 | useRef, 5 | useEffect, 6 | useCallback, 7 | } from 'react' 8 | import warning from 'tiny-warning' 9 | import type { AssignableRef } from './types' 10 | 11 | type AnyFunction = (...args: any[]) => unknown 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-empty-function 14 | export const noop = (): void => {} 15 | 16 | export function getElementHeight( 17 | el: RefObject | { current?: { scrollHeight: number } } 18 | ): string | number { 19 | if (!el?.current) { 20 | warning( 21 | true, 22 | `useCollapse was not able to find a ref to the collapse element via \`getCollapseProps\`. Ensure that the element exposes its \`ref\` prop. If it exposes the ref prop under a different name (like \`innerRef\`), use the \`refKey\` property to change it. Example: 23 | 24 | {...getCollapseProps({refKey: 'innerRef'})}` 25 | ) 26 | return 'auto' 27 | } 28 | return el.current.scrollHeight 29 | } 30 | 31 | // Helper function for render props. Sets a function to be called, plus any additional functions passed in 32 | export const callAll = 33 | (...fns: AnyFunction[]) => 34 | (...args: any[]): void => 35 | fns.forEach((fn) => fn && fn(...args)) 36 | 37 | // https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98 38 | export function getAutoHeightDuration(height: number | string): number { 39 | if (!height || typeof height === 'string') { 40 | return 0 41 | } 42 | 43 | const constant = height / 36 44 | 45 | // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 46 | return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10) 47 | } 48 | 49 | export function assignRef( 50 | ref: AssignableRef | null | undefined, 51 | value: any 52 | ) { 53 | if (ref == null) return 54 | if (typeof ref === 'function') { 55 | ref(value) 56 | } else { 57 | try { 58 | ref.current = value 59 | } catch (error) { 60 | throw new Error(`Cannot assign value "${value}" to ref "${ref}"`) 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Passes or assigns a value to multiple refs (typically a DOM node). Useful for 67 | * dealing with components that need an explicit ref for DOM calculations but 68 | * also forwards refs assigned by an app. 69 | * 70 | * @param refs Refs to fork 71 | */ 72 | export function mergeRefs( 73 | ...refs: (AssignableRef | null | undefined)[] 74 | ) { 75 | if (refs.every((ref) => ref == null)) { 76 | return null 77 | } 78 | return (node: any) => { 79 | refs.forEach((ref) => { 80 | assignRef(ref, node) 81 | }) 82 | } 83 | } 84 | 85 | export function useControlledState( 86 | isExpanded?: boolean, 87 | defaultExpanded?: boolean 88 | ): [boolean, React.Dispatch>] { 89 | const [stateExpanded, setStateExpanded] = useState(defaultExpanded || false) 90 | const initiallyControlled = useRef(isExpanded != null) 91 | const expanded = initiallyControlled.current 92 | ? (isExpanded as boolean) 93 | : stateExpanded 94 | const setExpanded = useCallback((n) => { 95 | if (!initiallyControlled.current) { 96 | setStateExpanded(n) 97 | } 98 | }, []) 99 | 100 | useEffect(() => { 101 | warning( 102 | !(initiallyControlled.current && isExpanded == null), 103 | '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.' 104 | ) 105 | warning( 106 | !(!initiallyControlled.current && isExpanded != null), 107 | '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.' 108 | ) 109 | }, [isExpanded]) 110 | 111 | return [expanded, setExpanded] 112 | } 113 | 114 | export function useEffectAfterMount( 115 | cb: () => void, 116 | dependencies: unknown[] 117 | ): void { 118 | const justMounted = useRef(true) 119 | // eslint-disable-next-line consistent-return 120 | useEffect(() => { 121 | if (!justMounted.current) { 122 | return cb() 123 | } 124 | justMounted.current = false 125 | // eslint-disable-next-line react-hooks/exhaustive-deps 126 | }, dependencies) 127 | } 128 | 129 | export function usePaddingWarning(element: RefObject): void { 130 | // @ts-ignore 131 | let warn = (el?: RefObject): void => {} 132 | 133 | if (process.env.NODE_ENV !== 'production') { 134 | warn = (el) => { 135 | if (!el?.current) { 136 | return 137 | } 138 | const { paddingTop, paddingBottom } = window.getComputedStyle(el.current) 139 | const hasPadding = 140 | (paddingTop && paddingTop !== '0px') || 141 | (paddingBottom && paddingBottom !== '0px') 142 | 143 | warning( 144 | !hasPadding, 145 | 'react-collapsed: Padding applied to the collapse element will cause the animation to break and not perform as expected. To fix, apply equivalent padding to the direct descendent of the collapse element.' 146 | ) 147 | } 148 | } 149 | 150 | useEffect(() => { 151 | warn(element) 152 | }, [element]) 153 | } 154 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["src/__tests__/**/*", "src/stories/**/*"], 4 | "compilerOptions": { 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "lib": ["dom", "ESNext"], 8 | "importHelpers": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "jsx": "react", 23 | "esModuleInterop": true, 24 | "skipLibCheck": true 25 | } 26 | } 27 | --------------------------------------------------------------------------------