├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .renovaterc.json ├── LICENSE ├── README.md ├── cypress.config.js ├── cypress ├── e2e │ ├── fade_in_not_out.cy.js │ ├── fade_in_out_display.cy.js │ ├── simple_fade.cy.js │ └── timeout.cy.js ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ ├── commands.js │ └── e2e.js ├── demo ├── demo.js ├── index.html └── styles.css ├── eslint.config.mjs ├── gulpfile.js ├── package-lock.json ├── package.json └── src └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | cypress-run: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Cypress run 21 | uses: cypress-io/github-action@v5 22 | with: 23 | start: npm start 24 | ci: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 🛎 28 | uses: actions/checkout@v4 29 | - name: Setup Node env 🏗 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 'lts/*' 33 | cache: 'npm' 34 | - name: Install dependencies 👨🏻‍💻 35 | run: npm ci 36 | - name: Run linter 👀 37 | run: npm run lint:check 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .vscode 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | dist/* 108 | 109 | **/.DS_Store 110 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:recommended", ":automergeMinor", ":pinVersions"], 3 | "minimumReleaseAge": "3 days" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cloud Four 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Transition Hidden Element 2 | 3 | [![NPM version](http://img.shields.io/npm/v/@cloudfour/transition-hidden-element.svg)](https://www.npmjs.org/package/@cloudfour/transition-hidden-element) [![Build Status](https://github.com/cloudfour/transition-hidden-element/workflows/CI/badge.svg)](https://github.com/cloudfour/transition-hidden-element/actions?query=workflow%3ACI) [![Renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com) 4 | 5 | > A JavaScript utility to help you use CSS transitions when showing and hiding elements with the `hidden` attribute or `display: none;`. 6 | 7 | ## Demos 8 | 9 | 1. [An example](https://codepen.io/phebert/pen/yLybwWY) showing this library in action. 10 | 2. [A more complex example](https://codepen.io/phebert/pen/QWwONMy) showing using this library with staggered child transitions and toggling. 11 | 12 | ## Why was this created? 13 | 14 | To [properly hide elements from all users including screen reader users](https://cloudfour.com/thinks/see-no-evil-hidden-content-and-accessibility/), elements should be hidden using the `hidden` attribute or `display: none;`. However, this prevents elements from being transitioned with CSS. If you'd like to use CSS transitions to show and hide these elements you'll need to use JavaScript to do so. This utility wraps that JavaScript into a small, easy-to-use module. 15 | 16 | I also [wrote a blog post](https://cloudfour.com/thinks/transitioning-hidden-elements/) going into more detail about why this was created and how it works. 17 | 18 | ## How it Works 19 | 20 | ### Showing Elements 21 | 22 | To allow transitions when showing an element the utility performs a few steps: 23 | 24 | 1. Remove the `hidden` attribute (or `display: none;`). 25 | 2. Trigger a browser reflow. 26 | 3. Apply a class to trigger the transition(s). 27 | 28 | ### Hiding Elements 29 | 30 | To allow transitions when hiding an element the utility performs a few steps: 31 | 32 | 1. Remove a class to trigger the transition(s). 33 | 2. Wait for the transition to complete, or wait for a timeout to complete. (Depending on initialization settings.) 34 | 3. Add the `hidden` attribute (or `display: none;`). 35 | 36 | ### Animated Children 37 | 38 | This library can be used to show or hide an element with transitioned children. For example, when opening a menu, each child link may animate in one-by-one in a staggered fashion. This utility includes API options to support this use case. 39 | 40 | ### Prefers Reduced Motion 41 | 42 | Animation can cause health consequences for some users and they may [prefer reduced motion](https://developers.google.com/web/updates/2019/03/prefers-reduced-motion). If a user's OS settings signal they prefer reduced motion you should disable your CSS transitions: 43 | 44 | ```css 45 | @media (prefers-reduced-motion: reduce) { 46 | * { 47 | transition: none !important; 48 | } 49 | } 50 | ``` 51 | 52 | ## Getting Started 53 | 54 | First, install the package from npm: 55 | 56 | ``` 57 | npm i @cloudfour/transition-hidden-element --save 58 | ``` 59 | 60 | Then you can get started. Here's a simple example showing importing the module, initializing a menu, and then showing and hiding it based on user interaction: 61 | 62 | ```js 63 | // Import our dependency 64 | import { transitionHiddenElement } from '@cloudfour/transition-hidden-element'; 65 | 66 | // Initialize our menu 67 | const menuTransitioner = transitionHiddenElement({ 68 | element: document.querySelector('#menu'), 69 | visibleClass: 'is-open', 70 | }); 71 | 72 | document.querySelector('#open-menu-button').addEventListener('click', () => { 73 | menuTransitioner.show(); 74 | }); 75 | 76 | document.querySelector('#close-menu-button').addEventListener('click', () => { 77 | menuTransitioner.close(); 78 | }); 79 | ``` 80 | 81 | ## Initialization Options 82 | 83 | When initializing `transitionHiddenElement`, there are two required parameters and four optional parameters: 84 | 85 | ```js 86 | const simpleFader = transitionHiddenElement({ 87 | element: document.querySelector('.js-simple-fade'), // Required 88 | visibleClass: 'is-shown', // Required 89 | waitMode: 'transitionend', // Optional — defaults to `'transitionend'` 90 | timeoutDuration: null // Optional — defaults to `null` 91 | hideMode: 'hidden', // Optional — defaults to `'hidden'` 92 | displayValue: null // Optional — defaults to `'block'` 93 | }); 94 | ``` 95 | 96 | ### element `{HTMLElement}` 97 | 98 | `element` should be the primary element we're showing and hiding. It will be the element that we'll be adding and removing classes and the `hidden` attribute (or `display: none;`) from. 99 | 100 | ### visibleClass `{String}` 101 | 102 | `visibleClass` is the class that will be added when showing our `element`. Adding the class should trigger a transition on our `element` or its child elements. 103 | 104 | ### waitMode `{String}` 105 | 106 | `waitMode` determines when the utility should re-apply the `hidden` attribute (or `display: none;`) when hiding. It defaults to `transitionend` but has a few options: 107 | 108 | 1. `transitionend` — Wait for the `element`'s `transitionend` event to fire. This works if the element has a transition that will be triggered by removing the `visibleClass`. 109 | 2. `timeout` — Wait a certain number of milliseconds. This is useful when your `element` is not the only element transitioning. For example, if removing your `visibleClass` triggers transitions on child elements, then you should use this option. When using this option be sure to pass in a number for the `timeoutDuration` parameter. 110 | 3. `immediate` — Don't wait at all. 111 | 112 | Regardless of which setting you choose, it will be converted to `immediate` if a user's OS settings signal they prefer reduced motion. You should disable other transitions in your CSS for these users as mentioned above. 113 | 114 | ### timeoutDuration `{Number}` 115 | 116 | When using the `timeout` option for `waitMode` you should be sure to pass in the length of the timeout in milliseconds. 117 | 118 | ### hideMode `{String}` 119 | 120 | `hideMode` determines whether elements are hidden by applying the `hidden` attribute, or using CSS's `display: none;`. It has two options: 121 | 122 | 1. `hidden` — use the `hidden` attribute (this is the default) 123 | 1. `display` — use CSS's `display: none;` 124 | 125 | ### displayValue `{String}` 126 | 127 | When using the `display` option for `hideMode`, this option determines what `display` should be set to when showing elements. e.g. `block`, `inline`, `inline-block`, etc. 128 | 129 | ## Object Methods 130 | 131 | After initializing your `transitionHiddenElement` it will return an object with a few methods. 132 | 133 | ### show() 134 | 135 | Shows your `element`. Removes `hidden` (or `display: none;`), triggers a document reflow, and applies your `visibleClass`. 136 | 137 | ### hide() 138 | 139 | Hides your `element`. Removes your `visibleClass` and adds `hidden` (or `display: none;`). 140 | 141 | ### toggle() 142 | 143 | Toggles the visibility of your `element`. Shows it if it's hidden and hides it if it's visible. 144 | 145 | ### isHidden() 146 | 147 | Returns the current hidden status of your `element`. It returns `true` if the element has the `hidden` attribute, is `display: none;` or is missing the `visibleClass`. 148 | 149 | ## Development 150 | 151 | Feel free to fork the repo and submit a PR with any helpful additions or changes. After cloning the repository run the following commands: 152 | 153 | 1. `npm i` — Install dependencies 154 | 2. `npm start` - Build and serve a demo server with hot reloading. 155 | 3. View the demo server at `localhost:3000` 156 | 157 | ### Testing 158 | 159 | Testing is done in the browser using Cypress, since [virtual DOM libraries like jsdom don't handle transitions well](https://github.com/jsdom/jsdom/issues/1781). 160 | 161 | In order to run the tests do the following: 162 | 163 | 1. `npm start` — launch the server 164 | 2. `npm test` — launch Cypress 165 | 166 | Tests will also automatically be run when pull requests are created. 167 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress'); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | // We've imported your old cypress plugins here. 6 | // You may want to clean this up later by importing these. 7 | setupNodeEvents(on, config) { 8 | return require('./cypress/plugins/index.js')(on, config); 9 | }, 10 | baseUrl: 'http://localhost:3000', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/e2e/fade_in_not_out.cy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/filename-case */ 2 | const opacityIsTransitioning = (element) => { 3 | const opacity = globalThis 4 | .getComputedStyle(element) 5 | .getPropertyValue('opacity'); 6 | return opacity > 0 && opacity < 1; 7 | }; 8 | 9 | describe('Fade In But Not Out', () => { 10 | it('Showing', () => { 11 | cy.visit('/').then((contextWindow) => { 12 | cy.log('Check initial state'); 13 | cy.get('.js-fade-in').should('have.attr', 'hidden', 'hidden'); 14 | 15 | cy.log('Trigger `show()`'); 16 | cy.get('.js-show-fade-in').click(); 17 | 18 | cy.log('Check that hidden has been toggled'); 19 | cy.get('.js-fade-in').should('not.have.attr', 'hidden'); 20 | 21 | cy.log('Wait for transition to begin'); 22 | cy.wait(100); 23 | 24 | cy.log('Confirm element is transitioning'); 25 | cy.wrap({ transitioning: opacityIsTransitioning }) 26 | .invoke( 27 | 'transitioning', 28 | contextWindow.document.querySelector('.js-fade-in'), 29 | ) 30 | .should('be.true'); 31 | }); 32 | }); 33 | 34 | it('Hiding', () => { 35 | cy.visit('/').then((contextWindow) => { 36 | cy.log('Override initial state'); 37 | cy.get('.js-fade-in').then((fader) => { 38 | fader[0].removeAttribute('hidden'); 39 | fader[0].classList.add('is-shown'); 40 | }); 41 | 42 | cy.log('Confirm state override was successful'); 43 | cy.get('.js-fade-in').should('not.have.attr', 'hidden'); 44 | 45 | cy.log('Trigger `hide()`'); 46 | cy.get('.js-hide-fade-in').click(); 47 | 48 | cy.log('Confirm `hidden` is removed immediately'); 49 | cy.get('.js-fade-in').should('have.attr', 'hidden'); 50 | 51 | cy.log('Wait for when transition would normally be in progress'); 52 | cy.wait(100); 53 | 54 | cy.log('Confirm element is not transitioning'); 55 | cy.wrap({ transitioning: opacityIsTransitioning }) 56 | .invoke( 57 | 'transitioning', 58 | contextWindow.document.querySelector('.js-fade-in'), 59 | ) 60 | .should('be.false'); 61 | }); 62 | }); 63 | 64 | it('Toggling', () => { 65 | cy.visit('/').then((contextWindow) => { 66 | cy.log('Check initial state'); 67 | cy.get('.js-fade-in').should('have.attr', 'hidden'); 68 | 69 | cy.log('Trigger `toggle()` (show)'); 70 | cy.get('.js-toggle-fade-in').click(); 71 | 72 | cy.log('Confirm the hidden attribute has been removed'); 73 | cy.get('.js-fade-in').should('not.have.attr', 'hidden'); 74 | 75 | cy.log('Wait for transition to begin'); 76 | cy.wait(100); 77 | 78 | cy.log('Confirm element is transitioning'); 79 | cy.wrap({ transitioning: opacityIsTransitioning }) 80 | .invoke( 81 | 'transitioning', 82 | contextWindow.document.querySelector('.js-fade-in'), 83 | ) 84 | .should('be.true'); 85 | 86 | cy.log('Trigger another `toggle()` (hide)'); 87 | cy.get('.js-toggle-fade-in').click(); 88 | 89 | cy.log('Confirm `hidden` is added immediately'); 90 | cy.get('.js-fade-in').should('have.attr', 'hidden'); 91 | 92 | cy.log('Wait for when transition would normally be in progress'); 93 | cy.wait(100); 94 | 95 | cy.log('Confirm element is not transitioning'); 96 | cy.wrap({ transitioning: opacityIsTransitioning }) 97 | .invoke( 98 | 'transitioning', 99 | contextWindow.document.querySelector('.js-fade-in'), 100 | ) 101 | .should('be.false'); 102 | }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /cypress/e2e/fade_in_out_display.cy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/filename-case */ 2 | const opacityIsTransitioning = (element) => { 3 | const opacity = globalThis 4 | .getComputedStyle(element) 5 | .getPropertyValue('opacity'); 6 | return opacity > 0 && opacity < 1; 7 | }; 8 | 9 | describe('Fade In and Out using Display', () => { 10 | it('Showing', () => { 11 | cy.visit('/').then((contextWindow) => { 12 | cy.log('Check initial state'); 13 | cy.get('.js-fade-in-out-display') 14 | .should('have.css', 'display') 15 | .and('eq', 'none'); 16 | 17 | cy.log('Trigger `show()`'); 18 | cy.get('.js-show-fade-in-out-display').click(); 19 | 20 | cy.log('Check that display has been toggled'); 21 | cy.get('.js-fade-in-out-display') 22 | .should('have.css', 'display') 23 | .and('eq', 'block'); 24 | 25 | cy.log('Wait for transition to begin'); 26 | cy.wait(100); 27 | 28 | cy.log('Confirm element is transitioning'); 29 | cy.wrap({ transitioning: opacityIsTransitioning }) 30 | .invoke( 31 | 'transitioning', 32 | contextWindow.document.querySelector('.js-fade-in-out-display'), 33 | ) 34 | .should('be.true'); 35 | }); 36 | }); 37 | 38 | it('Hiding', () => { 39 | cy.visit('/').then((contextWindow) => { 40 | cy.log('Override initial state'); 41 | cy.get('.js-fade-in-out-display').then((fader) => { 42 | fader[0].style.display = 'block'; 43 | fader[0].classList.add('is-shown'); 44 | }); 45 | 46 | cy.log('Confirm state override was successful'); 47 | cy.get('.js-fade-in-out-display') 48 | .should('have.css', 'display') 49 | .and('eq', 'block'); 50 | 51 | cy.log('Trigger `hide()`'); 52 | cy.get('.js-hide-fade-in-out-display').click(); 53 | 54 | cy.log('Wait for transition to begin'); 55 | cy.wait(100); 56 | 57 | cy.log('Confirm element is transitioning'); 58 | cy.wrap({ transitioning: opacityIsTransitioning }) 59 | .invoke( 60 | 'transitioning', 61 | contextWindow.document.querySelector('.js-fade-in-out-display'), 62 | ) 63 | .should('be.true'); 64 | 65 | cy.log('Confirm `display` is not toggled during the transition'); 66 | cy.get('.js-fade-in-out-display') 67 | .should('have.css', 'display') 68 | .and('eq', 'block'); 69 | 70 | cy.log('Wait for transition to end'); 71 | cy.wait(300); 72 | 73 | cy.log('Confirm `display` is toggled when the transition ends'); 74 | cy.get('.js-fade-in-out-display') 75 | .should('have.css', 'display') 76 | .and('eq', 'none'); 77 | }); 78 | }); 79 | 80 | it('Toggling', () => { 81 | cy.visit('/').then((contextWindow) => { 82 | cy.log('Check initial state'); 83 | cy.get('.js-fade-in-out-display') 84 | .should('have.css', 'display') 85 | .and('eq', 'none'); 86 | 87 | cy.log('Trigger `toggle()` (show)'); 88 | cy.get('.js-toggle-fade-in-out-display').click(); 89 | 90 | cy.log('Confirm display has been toggled'); 91 | cy.get('.js-fade-in-out-display') 92 | .should('have.css', 'display') 93 | .and('eq', 'block'); 94 | 95 | cy.log('Wait for transition to begin'); 96 | cy.wait(100); 97 | 98 | cy.log('Confirm element is transitioning'); 99 | cy.wrap({ transitioning: opacityIsTransitioning }) 100 | .invoke( 101 | 'transitioning', 102 | contextWindow.document.querySelector('.js-fade-in-out-display'), 103 | ) 104 | .should('be.true'); 105 | 106 | cy.log('Wait for transition to end'); 107 | cy.wait(300); 108 | 109 | cy.log('Trigger another `toggle()` (hide)'); 110 | cy.get('.js-toggle-fade-in-out-display').click(); 111 | 112 | cy.log('Wait for transition to begin'); 113 | cy.wait(100); 114 | 115 | cy.log('Confirm element is transitioning'); 116 | cy.wrap({ transitioning: opacityIsTransitioning }) 117 | .invoke( 118 | 'transitioning', 119 | contextWindow.document.querySelector('.js-fade-in-out-display'), 120 | ) 121 | .should('be.true'); 122 | 123 | cy.log('Confirm display is not toggled during the transition'); 124 | cy.get('.js-fade-in-out-display') 125 | .should('have.css', 'display') 126 | .and('eq', 'block'); 127 | 128 | cy.log('Wait for transition to end'); 129 | cy.wait(300); 130 | 131 | cy.log('Confirm display is toggled when the transition ends'); 132 | cy.get('.js-fade-in-out-display') 133 | .should('have.css', 'display') 134 | .and('eq', 'none'); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /cypress/e2e/simple_fade.cy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/filename-case */ 2 | const opacityIsTransitioning = (element) => { 3 | const opacity = globalThis 4 | .getComputedStyle(element) 5 | .getPropertyValue('opacity'); 6 | return opacity > 0 && opacity < 1; 7 | }; 8 | 9 | describe('Simple Fade', () => { 10 | it('Showing', () => { 11 | cy.visit('/').then((contextWindow) => { 12 | cy.log('Check initial state'); 13 | cy.get('.js-simple-fade').should('have.attr', 'hidden', 'hidden'); 14 | 15 | cy.log('Trigger `show()`'); 16 | cy.get('.js-show-simple-fade').click(); 17 | 18 | cy.log('Check that hidden has been toggled'); 19 | cy.get('.js-simple-fade').should('not.have.attr', 'hidden'); 20 | 21 | cy.log('Wait for transition to begin'); 22 | cy.wait(100); 23 | 24 | cy.log('Confirm element is transitioning'); 25 | cy.wrap({ transitioning: opacityIsTransitioning }) 26 | .invoke( 27 | 'transitioning', 28 | contextWindow.document.querySelector('.js-simple-fade'), 29 | ) 30 | .should('be.true'); 31 | }); 32 | }); 33 | 34 | it('Hiding', () => { 35 | cy.visit('/').then((contextWindow) => { 36 | cy.log('Override initial state'); 37 | cy.get('.js-simple-fade').then((fader) => { 38 | fader[0].removeAttribute('hidden'); 39 | fader[0].classList.add('is-shown'); 40 | }); 41 | 42 | cy.log('Confirm state override was successful'); 43 | cy.get('.js-simple-fade').should('not.have.attr', 'hidden'); 44 | 45 | cy.log('Trigger `hide()`'); 46 | cy.get('.js-hide-simple-fade').click(); 47 | 48 | cy.log('Wait for transition to begin'); 49 | cy.wait(100); 50 | 51 | cy.log('Confirm element is transitioning'); 52 | cy.wrap({ transitioning: opacityIsTransitioning }) 53 | .invoke( 54 | 'transitioning', 55 | contextWindow.document.querySelector('.js-simple-fade'), 56 | ) 57 | .should('be.true'); 58 | 59 | cy.log('Confirm `hidden` is not added during the transition'); 60 | cy.get('.js-simple-fade').should('not.have.attr', 'hidden'); 61 | 62 | cy.log('Wait for transition to end'); 63 | cy.wait(300); 64 | 65 | cy.log('Confirm `hidden` is added when the transition ends'); 66 | cy.get('.js-simple-fade').should('have.attr', 'hidden'); 67 | }); 68 | }); 69 | 70 | it('Toggling', () => { 71 | cy.visit('/').then((contextWindow) => { 72 | cy.log('Check initial state'); 73 | cy.get('.js-simple-fade').should('have.attr', 'hidden'); 74 | 75 | cy.log('Trigger `toggle()` (show)'); 76 | cy.get('.js-toggle-simple-fade').click(); 77 | 78 | cy.log('Confirm the hidden attribute has been removed'); 79 | cy.get('.js-simple-fade').should('not.have.attr', 'hidden'); 80 | 81 | cy.log('Wait for transition to begin'); 82 | cy.wait(100); 83 | 84 | cy.log('Confirm element is transitioning'); 85 | cy.wrap({ transitioning: opacityIsTransitioning }) 86 | .invoke( 87 | 'transitioning', 88 | contextWindow.document.querySelector('.js-simple-fade'), 89 | ) 90 | .should('be.true'); 91 | 92 | cy.log('Wait for transition to end'); 93 | cy.wait(300); 94 | 95 | cy.log('Trigger another `toggle()` (hide)'); 96 | cy.get('.js-toggle-simple-fade').click(); 97 | 98 | cy.log('Wait for transition to begin'); 99 | cy.wait(100); 100 | 101 | cy.log('Confirm element is transitioning'); 102 | cy.wrap({ transitioning: opacityIsTransitioning }) 103 | .invoke( 104 | 'transitioning', 105 | contextWindow.document.querySelector('.js-simple-fade'), 106 | ) 107 | .should('be.true'); 108 | 109 | cy.log('Confirm `hidden` is not added during the transition'); 110 | cy.get('.js-simple-fade').should('not.have.attr', 'hidden'); 111 | 112 | cy.log('Wait for transition to end'); 113 | cy.wait(300); 114 | 115 | cy.log('Confirm `hidden` is added when the transition ends'); 116 | cy.get('.js-simple-fade').should('have.attr', 'hidden'); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /cypress/e2e/timeout.cy.js: -------------------------------------------------------------------------------- 1 | const opacityIsTransitioning = (element) => { 2 | const opacity = globalThis 3 | .getComputedStyle(element) 4 | .getPropertyValue('opacity'); 5 | return opacity > 0 && opacity < 1; 6 | }; 7 | 8 | describe('Fade With Timeout waitMode', () => { 9 | it('Showing', () => { 10 | cy.visit('/').then((contextWindow) => { 11 | cy.log('Check initial state'); 12 | cy.get('.js-fade-out-timeout').should('have.attr', 'hidden', 'hidden'); 13 | 14 | cy.log('Trigger `show()`'); 15 | cy.get('.js-show-fade-out-timeout').click(); 16 | 17 | cy.log('Check that hidden has been toggled'); 18 | cy.get('.js-fade-out-timeout').should('not.have.attr', 'hidden'); 19 | 20 | cy.log('Wait for transition to begin'); 21 | cy.wait(100); 22 | 23 | cy.log('Confirm element is transitioning'); 24 | cy.wrap({ transitioning: opacityIsTransitioning }) 25 | .invoke( 26 | 'transitioning', 27 | contextWindow.document.querySelector('.js-fade-out-timeout'), 28 | ) 29 | .should('be.true'); 30 | }); 31 | }); 32 | 33 | it('Hiding', () => { 34 | cy.visit('/').then((contextWindow) => { 35 | cy.log('Override initial state'); 36 | cy.get('.js-fade-out-timeout').then((fader) => { 37 | fader[0].removeAttribute('hidden'); 38 | fader[0].classList.add('is-shown'); 39 | }); 40 | 41 | cy.log('Confirm state override was successful'); 42 | cy.get('.js-fade-out-timeout').should('not.have.attr', 'hidden'); 43 | 44 | cy.log('Trigger `hide()`'); 45 | cy.get('.js-hide-fade-out-timeout').click(); 46 | 47 | cy.log('Wait for transition to begin'); 48 | cy.wait(100); 49 | 50 | cy.log('Confirm element is transitioning'); 51 | cy.wrap({ transitioning: opacityIsTransitioning }) 52 | .invoke( 53 | 'transitioning', 54 | contextWindow.document.querySelector('.js-fade-out-timeout'), 55 | ) 56 | .should('be.true'); 57 | 58 | cy.log('Confirm `hidden` is not added until after the timeout'); 59 | cy.get('.js-fade-out-timeout').should('not.have.attr', 'hidden'); 60 | 61 | cy.log('Wait for timeout to end'); 62 | cy.wait(300); 63 | 64 | cy.log('Confirm `hidden` is added when the timeout ends'); 65 | cy.get('.js-fade-out-timeout').should('have.attr', 'hidden'); 66 | }); 67 | }); 68 | 69 | it('Toggling', () => { 70 | cy.visit('/').then((contextWindow) => { 71 | cy.log('Check initial state'); 72 | cy.get('.js-fade-out-timeout').should('have.attr', 'hidden'); 73 | 74 | cy.log('Trigger `toggle()` (show)'); 75 | cy.get('.js-toggle-fade-out-timeout').click(); 76 | 77 | cy.log('Confirm the hidden attribute has been removed'); 78 | cy.get('.js-fade-out-timeout').should('not.have.attr', 'hidden'); 79 | 80 | cy.log('Wait for transition to begin'); 81 | cy.wait(100); 82 | 83 | cy.log('Confirm element is transitioning'); 84 | cy.wrap({ transitioning: opacityIsTransitioning }) 85 | .invoke( 86 | 'transitioning', 87 | contextWindow.document.querySelector('.js-fade-out-timeout'), 88 | ) 89 | .should('be.true'); 90 | 91 | cy.log('Wait for transition to end'); 92 | cy.wait(300); 93 | 94 | cy.log('Trigger another `toggle()` (hide)'); 95 | cy.get('.js-toggle-fade-out-timeout').click(); 96 | 97 | cy.log('Wait for transition to begin'); 98 | cy.wait(100); 99 | 100 | cy.log('Confirm element is transitioning'); 101 | cy.wrap({ transitioning: opacityIsTransitioning }) 102 | .invoke( 103 | 'transitioning', 104 | contextWindow.document.querySelector('.js-fade-out-timeout'), 105 | ) 106 | .should('be.true'); 107 | 108 | cy.log('Confirm `hidden` is not added during the timeout'); 109 | cy.get('.js-fade-out-timeout').should('not.have.attr', 'hidden'); 110 | 111 | cy.log('Wait for transition to end'); 112 | cy.wait(300); 113 | 114 | cy.log('Confirm `hidden` is added when the timeout ends'); 115 | cy.get('.js-fade-out-timeout').should('have.attr', 'hidden'); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | const index = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | }; 18 | module.exports = index; 19 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-empty-file */ 2 | // *********************************************** 3 | // This example commands.js shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add("login", (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 27 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands.js'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | import { transitionHiddenElement } from '../src/index.js'; 2 | 3 | const simpleFader = transitionHiddenElement({ 4 | element: document.querySelector('.js-simple-fade'), 5 | visibleClass: 'is-shown', 6 | }); 7 | 8 | document.querySelector('.js-show-simple-fade').addEventListener('click', () => { 9 | simpleFader.show(); 10 | }); 11 | 12 | document.querySelector('.js-hide-simple-fade').addEventListener('click', () => { 13 | simpleFader.hide(); 14 | }); 15 | 16 | document 17 | .querySelector('.js-toggle-simple-fade') 18 | .addEventListener('click', () => { 19 | simpleFader.toggle(); 20 | }); 21 | 22 | const fadeIn = transitionHiddenElement({ 23 | element: document.querySelector('.js-fade-in'), 24 | visibleClass: 'is-shown', 25 | waitMode: 'immediate', 26 | }); 27 | 28 | document.querySelector('.js-show-fade-in').addEventListener('click', () => { 29 | fadeIn.show(); 30 | }); 31 | 32 | document.querySelector('.js-hide-fade-in').addEventListener('click', () => { 33 | fadeIn.hide(); 34 | }); 35 | 36 | document.querySelector('.js-toggle-fade-in').addEventListener('click', () => { 37 | fadeIn.toggle(); 38 | }); 39 | 40 | const fadeOutTimeout = transitionHiddenElement({ 41 | element: document.querySelector('.js-fade-out-timeout'), 42 | visibleClass: 'is-shown', 43 | waitMode: 'timeout', 44 | timeoutDuration: 300, 45 | }); 46 | 47 | document 48 | .querySelector('.js-show-fade-out-timeout') 49 | .addEventListener('click', () => { 50 | fadeOutTimeout.show(); 51 | }); 52 | 53 | document 54 | .querySelector('.js-hide-fade-out-timeout') 55 | .addEventListener('click', () => { 56 | fadeOutTimeout.hide(); 57 | }); 58 | 59 | document 60 | .querySelector('.js-toggle-fade-out-timeout') 61 | .addEventListener('click', () => { 62 | fadeOutTimeout.toggle(); 63 | }); 64 | 65 | const fadeInOutDisplay = transitionHiddenElement({ 66 | element: document.querySelector('.js-fade-in-out-display'), 67 | visibleClass: 'is-shown', 68 | hideMode: 'display', 69 | displayValue: 'block', 70 | }); 71 | 72 | document 73 | .querySelector('.js-show-fade-in-out-display') 74 | .addEventListener('click', () => { 75 | fadeInOutDisplay.show(); 76 | }); 77 | 78 | document 79 | .querySelector('.js-hide-fade-in-out-display') 80 | .addEventListener('click', () => { 81 | fadeInOutDisplay.hide(); 82 | }); 83 | 84 | document 85 | .querySelector('.js-toggle-fade-in-out-display') 86 | .addEventListener('click', () => { 87 | fadeInOutDisplay.toggle(); 88 | }); 89 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Transition Hidden Element Demo 5 | 6 | 7 | 8 | 9 |

Transition Hidden Element Demo

10 | 11 |

12 | A demo showing this library in action with different setting configured. 13 |

14 | 15 |

Default Settings

16 | 17 |

Fade an element in and out

18 | 19 |
 20 |       
 21 | const simpleFader = transitionHiddenElement({
 22 |   element: document.querySelector('.js-simple-fade'),
 23 |   visibleClass: 'is-shown'
 24 | });
 25 |       
 26 |     
27 | 28 | 29 | 30 | 31 | 32 | 36 | 37 |

Fade In But Not Out

38 | 39 |
 40 |       
 41 | const fadeIn = transitionHiddenElement({
 42 |   element: document.querySelector('.js-fade-in'),
 43 |   visibleClass: 'is-shown',
 44 |   waitMode: 'immediate'
 45 | });
 46 |       
 47 |     
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |

Fade Out With a Timeout

56 | 57 |
 58 |       
 59 | const fadeOutTimeout = transitionHiddenElement({
 60 |   element: document.querySelector('.js-fade-out-timeout'),
 61 |   visibleClass: 'is-shown',
 62 |   waitMode: 'timeout',
 63 |   timeoutDuration: 300
 64 | });
 65 |       
 66 |     
67 | 68 | 69 | 70 | 71 | 72 | 76 | 77 |

Fade In and Out with `display: none;`

78 | 79 |
 80 |       
 81 | const fadeInOutDisplay = transitionHiddenElement({
 82 |   element: document.querySelector('.js-fade-in-out-display'),
 83 |   visibleClass: 'is-shown',
 84 |   hideMode: 'display',
 85 |   displayValue: 'block'
 86 | });
 87 |       
 88 |     
89 | 90 | 91 | 92 | 93 | 94 | 98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | .simple-fade { 2 | opacity: 0; 3 | transition: opacity 0.3s; 4 | } 5 | 6 | .simple-fade.is-shown { 7 | opacity: 1; 8 | } 9 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-unpublished-import */ 2 | import cloudFourConfig from '@cloudfour/eslint-config'; 3 | import pluginCypress from 'eslint-plugin-cypress/flat'; 4 | import pluginJest from 'eslint-plugin-jest'; 5 | 6 | export default [ 7 | { 8 | ignores: ['dist/**/*'], 9 | }, 10 | ...cloudFourConfig, 11 | pluginCypress.configs.recommended, 12 | { 13 | rules: { 14 | 'unicorn/expiring-todo-comments': 'off', 15 | }, 16 | }, 17 | { 18 | files: ['**/*.cy.js'], 19 | ...pluginJest.configs['flat/recommended'], 20 | rules: { 21 | ...pluginJest.configs['flat/recommended'].rules, 22 | 'jest/expect-expect': 'off', // This doesn't apply to Cypress tests. 23 | // This rule is probably a good idea, but I don't want to refactor right now. 24 | 'cypress/no-unnecessary-waiting': 'off', 25 | }, 26 | settings: { 27 | jest: { 28 | version: 27, 29 | }, 30 | }, 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const { babel } = require('@rollup/plugin-babel'); 2 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 3 | const browserSync = require('browser-sync'); 4 | const gulp = require('gulp'); 5 | const clean = require('gulp-clean'); 6 | const gulpCopy = require('gulp-copy'); 7 | const { rollup } = require('rollup'); 8 | 9 | /** 10 | * A gulp task to process our javascript. 11 | * 12 | * This is a very basic setup. We'll likely want to configure this further. 13 | */ 14 | gulp.task('js', async () => { 15 | const rollupBuild = await rollup({ 16 | input: 'demo/demo.js', 17 | plugins: [babel(), nodeResolve()], 18 | }); 19 | await rollupBuild.write({ 20 | file: 'dist/demo.js', 21 | format: 'iife', 22 | }); 23 | await rollupBuild.close(); 24 | }); 25 | 26 | /** 27 | * A gulp task to copy our other demo files to our dist folder 28 | */ 29 | gulp.task('content', () => 30 | gulp 31 | .src(['demo/index.html', 'demo/styles.css']) 32 | .pipe(gulpCopy('dist', { prefix: 1 })), 33 | ); 34 | 35 | /** 36 | * Clean out old content 37 | */ 38 | gulp.task('clean', () => 39 | gulp 40 | .src('dist', { 41 | allowEmpty: true, 42 | read: false, 43 | }) 44 | .pipe(clean()), 45 | ); 46 | 47 | /** 48 | * Watch for file changes 49 | */ 50 | gulp.task('watch', () => { 51 | gulp.watch('*(src|demo)/**/*.js', gulp.series('js', 'reload')); 52 | gulp.watch('demo/**/*.*(html|css)', gulp.series('content', 'reload')); 53 | }); 54 | 55 | /** 56 | * Serve files 57 | */ 58 | gulp.task('serve', () => { 59 | browserSync.init({ 60 | notify: false, 61 | server: { baseDir: './dist' }, 62 | }); 63 | }); 64 | 65 | /** 66 | * Serve updated files 67 | */ 68 | gulp.task('reload', (callback) => { 69 | browserSync.reload(); 70 | callback(); 71 | }); 72 | 73 | /** 74 | * Start up gulp 75 | */ 76 | gulp.task( 77 | 'default', 78 | gulp.series( 79 | 'clean', 80 | gulp.parallel('js', 'content'), 81 | gulp.parallel('serve', 'watch'), 82 | ), 83 | ); 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudfour/transition-hidden-element", 3 | "version": "2.0.2", 4 | "description": "A JavaScript utility to help you use CSS transitions when showing and hiding elements with the `hidden` attribute.", 5 | "main": "src/index.js", 6 | "devDependencies": { 7 | "@cloudfour/eslint-config": "24.0.0", 8 | "@rollup/plugin-babel": "6.0.4", 9 | "@rollup/plugin-node-resolve": "16.0.1", 10 | "browser-sync": "3.0.4", 11 | "cypress": "13.17.0", 12 | "eslint": "9.28.0", 13 | "eslint-plugin-cypress": "4.3.0", 14 | "eslint-plugin-jest": "28.12.0", 15 | "gulp": "5.0.1", 16 | "gulp-clean": "0.4.0", 17 | "gulp-copy": "5.0.0", 18 | "npm-run-all2": "7.0.2", 19 | "prettier": "3.5.3", 20 | "rollup": "4.41.1" 21 | }, 22 | "scripts": { 23 | "start": "npx gulp", 24 | "test": "cypress open", 25 | "lint": "run-s lint:js lint:prettier", 26 | "lint:check": "run-s lint:*:check", 27 | "lint:js": "eslint --fix .", 28 | "lint:js:check": "eslint .", 29 | "lint:prettier": "prettier --write . --ignore-path .gitignore", 30 | "lint:prettier:check": "prettier --check . --ignore-path .gitignore" 31 | }, 32 | "prettier": { 33 | "singleQuote": true 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/cloudfour/transition-hidden-element.git" 38 | }, 39 | "keywords": [ 40 | "transition", 41 | "visibility", 42 | "hidden", 43 | "a11y", 44 | "animation", 45 | "css" 46 | ], 47 | "author": "Cloud Four", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/cloudfour/transition-hidden-element/issues" 51 | }, 52 | "homepage": "https://github.com/cloudfour/transition-hidden-element#readme" 53 | } 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Transition Hidden Element 3 | * 4 | * A utility to wrap elements that need to be shown and hidden with transitions. 5 | * 6 | * Enables transitions on elements with the `hidden` attribute 7 | * by removing the attribute and then forcing a reflow. It also has options to 8 | * wait for exit animations before re-applying `hidden`. 9 | * 10 | * @param {object} opts - Our options element, destructed into its properties 11 | * @param {HTMLElement} opts.element - The element we're showing and hiding 12 | * @param {string} opts.visibleClass - The class to add when showing the element 13 | * @param {string} opts.waitMode - Determine how the library should check that 14 | * hiding transitions are complete. The options are `'transitionEnd'`, 15 | * `'timeout'`, and `'immediate'` (to hide immediately) 16 | * @param {number} opts.timeoutDuration — If `waitMode` is set to `'timeout'`, 17 | * then this determines the length of the timeout. 18 | * @param {string} opts.hideMode - Determine how the library should hide 19 | * elements. The options are `hidden` (use the `hidden` attribute), and 20 | * `display` (use the CSS `display` property). Defaults to `hidden` 21 | * @param {string} opts.displayValue - When using the `display` `hideMode`, this 22 | * parameter determines what the CSS `display` property should be set to when 23 | * the element is shown. e.g. `block`, `inline`, `inline-block`. Defaults to 24 | * `block`. 25 | */ 26 | export function transitionHiddenElement({ 27 | element, 28 | visibleClass, 29 | waitMode = 'transitionend', 30 | timeoutDuration, 31 | hideMode = 'hidden', 32 | displayValue = 'block', 33 | }) { 34 | if (waitMode === 'timeout' && typeof timeoutDuration !== 'number') { 35 | console.error(` 36 | When calling transitionHiddenElement with waitMode set to timeout, 37 | you must pass in a number for timeoutDuration. 38 | `); 39 | 40 | return; 41 | } 42 | 43 | // Don't wait for exit transitions if a user prefers reduced motion. 44 | // Ideally transitions will be disabled in CSS, which means we should not wait 45 | // before adding `hidden`. 46 | if (globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches) { 47 | waitMode = 'immediate'; 48 | } 49 | 50 | /** 51 | * An event listener to add `hidden` after our animations complete. 52 | * This listener will remove itself after completing. 53 | * 54 | * @param {Event} e 55 | */ 56 | const listener = (e) => { 57 | // Confirm `transitionend` was called on our `element` and didn't bubble 58 | // up from a child element. 59 | if (e.target === element) { 60 | applyHiddenAttributes(); 61 | 62 | element.removeEventListener('transitionend', listener); 63 | } 64 | }; 65 | 66 | const applyHiddenAttributes = () => { 67 | if (hideMode === 'display') { 68 | element.style.display = 'none'; 69 | } else { 70 | element.setAttribute('hidden', true); 71 | } 72 | }; 73 | 74 | const removeHiddenAttributes = () => { 75 | if (hideMode === 'display') { 76 | element.style.display = displayValue; 77 | } else { 78 | element.removeAttribute('hidden'); 79 | } 80 | }; 81 | 82 | return { 83 | /** 84 | * Show the element 85 | */ 86 | show() { 87 | /** 88 | * This listener shouldn't be here but if someone spams the toggle 89 | * over and over really fast it can incorrectly stick around. 90 | * We remove it just to be safe. 91 | */ 92 | element.removeEventListener('transitionend', listener); 93 | 94 | /** 95 | * Similarly, we'll clear the timeout in case it's still hanging around. 96 | */ 97 | if (this.timeout) { 98 | clearTimeout(this.timeout); 99 | } 100 | 101 | removeHiddenAttributes(); 102 | 103 | /** 104 | * Force a browser re-paint so the browser will realize the 105 | * element is no longer `hidden` and allow transitions. 106 | */ 107 | // eslint-disable-next-line no-unused-vars 108 | const reflow = element.offsetHeight; 109 | 110 | element.classList.add(visibleClass); 111 | }, 112 | 113 | /** 114 | * Hide the element 115 | */ 116 | hide() { 117 | if (waitMode === 'transitionend') { 118 | element.addEventListener('transitionend', listener); 119 | } else if (waitMode === 'timeout') { 120 | this.timeout = setTimeout(() => { 121 | applyHiddenAttributes(); 122 | }, timeoutDuration); 123 | } else { 124 | applyHiddenAttributes(); 125 | } 126 | 127 | // Add this class to trigger our animation 128 | element.classList.remove(visibleClass); 129 | }, 130 | 131 | /** 132 | * Toggle the element's visibility 133 | */ 134 | toggle() { 135 | if (this.isHidden()) { 136 | this.show(); 137 | } else { 138 | this.hide(); 139 | } 140 | }, 141 | 142 | /** 143 | * Tell whether the element is hidden or not. 144 | */ 145 | isHidden() { 146 | /** 147 | * The hidden attribute does not require a value. Since an empty string is 148 | * falsy, but shows the presence of an attribute we compare to `null` 149 | */ 150 | const hasHiddenAttribute = element.getAttribute('hidden') !== null; 151 | 152 | const isDisplayNone = element.style.display === 'none'; 153 | 154 | const hasVisibleClass = [...element.classList].includes(visibleClass); 155 | 156 | return hasHiddenAttribute || isDisplayNone || !hasVisibleClass; 157 | }, 158 | 159 | // A placeholder for our `timeout` 160 | timeout: null, 161 | }; 162 | } 163 | --------------------------------------------------------------------------------