├── playground ├── .npmrc ├── tsconfig.json ├── server │ └── tsconfig.json ├── public │ └── favicon.ico ├── .gitignore ├── nuxt.config.ts ├── package.json ├── components │ ├── ExampleHeader.vue │ ├── Accordion.vue │ ├── MountUnmount.vue │ ├── DisplayHide.vue │ ├── SingleCollapse.vue │ ├── IndividualControl.vue │ ├── WithCallbacks.vue │ ├── AdvancedControl.vue │ └── NestedCollapse.vue ├── utils │ ├── getHead.ts │ └── fakeData.ts ├── app.vue └── assets │ └── style.css ├── packages └── vue-collapsed │ ├── .gitignore │ ├── src │ ├── index.ts │ ├── constants.ts │ ├── utils.ts │ └── Collapse.vue │ ├── cypress.config.ts │ ├── cypress │ └── support │ │ ├── component-index.html │ │ └── component.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── package.json │ └── tests │ ├── App.vue │ └── Collapse.cy.ts ├── .github ├── FUNDING.yml └── workflows │ ├── chrome-tests.yml │ └── publish.yml ├── pnpm-workspace.yaml ├── .husky └── pre-commit ├── .vscode └── extensions.json ├── .gitignore ├── .prettierrc ├── package.json ├── LICENSE └── README.md /playground/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /packages/vue-collapsed/.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | polar: smastrom 2 | buy_me_a_coffee: smastrom 3 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'playground' 4 | -------------------------------------------------------------------------------- /packages/vue-collapsed/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Collapse } from './Collapse.vue' 2 | -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/vue-collapsed/HEAD/playground/public/favicon.ico -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | .output 2 | .data 3 | .nuxt 4 | .nitro 5 | .cache 6 | dist 7 | 8 | node_modules 9 | 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | node_modules 3 | .DS_Store 4 | dist 5 | /cypress/videos/ 6 | /cypress/screenshots/ 7 | .vscode/* 8 | !.vscode/extensions.json 9 | .idea 10 | *.tgz 11 | -------------------------------------------------------------------------------- /packages/vue-collapsed/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | component: { 5 | video: false, 6 | devServer: { 7 | framework: 'vue', 8 | bundler: 'vite', 9 | }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { getHead } from './utils/getHead' 2 | 3 | export default defineNuxtConfig({ 4 | ssr: true, 5 | app: { 6 | head: getHead(), 7 | }, 8 | nitro: { 9 | preset: 'cloudflare-pages', 10 | }, 11 | css: ['@/assets/style.css'], 12 | }) 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 3, 6 | "trailingComma": "es5", 7 | "useTabs": false, 8 | "overrides": [ 9 | { 10 | "files": "README.md", 11 | "options": { 12 | "semi": false, 13 | "tabWidth": 2, 14 | "trailingComma": "none", 15 | "useTabs": false 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/vue-collapsed/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/vue-collapsed/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2015", 5 | "moduleResolution": "Node", 6 | "strict": true, 7 | "isolatedModules": true, 8 | "lib": ["ESNext", "ES2015", "DOM"], 9 | "skipLibCheck": true, 10 | "noEmit": true, 11 | "resolveJsonModule": true, 12 | "allowSyntheticDefaultImports": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/vue-collapsed/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTO_DUR_VAR = '--vc-auto-duration' 2 | 3 | export const DEFAULT_TRANSITION = `height var(${AUTO_DUR_VAR}) cubic-bezier(0.33, 1, 0.68, 1)` 4 | 5 | export const SAFE_STYLES = { padding: 0 } as const 6 | 7 | export const FALLBACK_DURATION = 300 8 | 9 | export const VISUALLY_HIDDEN = { 10 | position: 'absolute', 11 | width: '1px', 12 | height: '1px', 13 | padding: '0', 14 | margin: '-1px', 15 | overflow: 'hidden', 16 | clip: 'rect(0, 0, 0, 0)', 17 | whiteSpace: 'nowrap', 18 | border: '0', 19 | } as const 20 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-collapsed-playground", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "npx nuxi cleanup && nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "vue-collapsed": "workspace:*" 14 | }, 15 | "devDependencies": { 16 | "@nuxt/devtools": "latest", 17 | "nuxt": "^3.11.2", 18 | "vue": "^3.4.21", 19 | "vue-router": "^4.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/vue-collapsed/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/vue' 2 | 3 | import 'cypress-wait-frames' 4 | 5 | declare global { 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | namespace Cypress { 8 | interface Chainable { 9 | mount: typeof mount 10 | } 11 | } 12 | } 13 | 14 | export function getRandomIntInclusive(min: number, max: number) { 15 | min = Math.ceil(min) 16 | max = Math.floor(max) 17 | return Math.floor(Math.random() * (max - min + 1) + min) 18 | } 19 | 20 | Cypress.Commands.add('mount', mount) 21 | 22 | export const isFirefox = Cypress.isBrowser('firefox') 23 | -------------------------------------------------------------------------------- /.github/workflows/chrome-tests.yml: -------------------------------------------------------------------------------- 1 | name: Chrome Tests 2 | 3 | on: 4 | push: 5 | workflow_call: 6 | 7 | jobs: 8 | cypress-ct: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 21 15 | - uses: pnpm/action-setup@v2 16 | name: Install pnpm 17 | with: 18 | version: 8 19 | run_install: false 20 | - name: Install deps 21 | run: pnpm i 22 | - name: Install Cypress binaries 23 | run: pnpm dlx cypress install 24 | - name: Chrome tests 25 | run: pnpm test:chrome 26 | -------------------------------------------------------------------------------- /packages/vue-collapsed/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | import vue from '@vitejs/plugin-vue' 4 | import dts from 'vite-plugin-dts' 5 | 6 | const isWatch = process.argv.includes('--watch') 7 | 8 | export default defineConfig({ 9 | build: { 10 | emptyOutDir: !isWatch, 11 | lib: { 12 | entry: 'src/index.ts', 13 | name: 'vue-collapsed', 14 | fileName: 'index', 15 | formats: ['es', 'cjs'], 16 | }, 17 | rollupOptions: { 18 | external: ['vue'], 19 | output: { 20 | globals: { 21 | vue: 'Vue', 22 | }, 23 | }, 24 | }, 25 | }, 26 | plugins: [ 27 | vue(), 28 | dts({ 29 | rollupTypes: true, 30 | }), 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-collapsed-monorepo", 3 | "private": true, 4 | "packageManager": "pnpm@8.12.1", 5 | "engines": { 6 | "node": ">=20.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "pnpm build && concurrently \"pnpm -C packages/vue-collapsed run watch\" \"pnpm -C playground install && pnpm -C playground run dev --host\"", 10 | "build": "pnpm -C packages/vue-collapsed run build", 11 | "build:app": "pnpm build && pnpm install && pnpm -C playground run build", 12 | "test:chrome": "pnpm -C packages/vue-collapsed run test:chrome", 13 | "test:firefox": "pnpm -C packages/vue-collapsed run test:firefox", 14 | "test:gui": "pnpm -C packages/vue-collapsed run test:gui", 15 | "prepare": "husky install" 16 | }, 17 | "devDependencies": { 18 | "concurrently": "^8.2.2", 19 | "husky": "^8.0.3", 20 | "lint-staged": "^15.2.2", 21 | "prettier": "^3.2.5" 22 | }, 23 | "lint-staged": { 24 | "*.{js,ts,vue,json,css,md}": "prettier --write" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022-present Simone Mastromattei 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | chrome-tests: 10 | uses: ./.github/workflows/chrome-tests.yml 11 | publish: 12 | needs: [chrome-tests] 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: '21.x' 22 | registry-url: 'https://registry.npmjs.org' 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | with: 26 | version: 8 27 | run_install: true 28 | - name: Build 29 | run: pnpm build 30 | - name: Pack 31 | run: cd packages/vue-collapsed && rm -rf *.tgz && npm pack 32 | - name: Publish 33 | run: cd packages/vue-collapsed && npm publish *.tgz --provenance 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /playground/components/ExampleHeader.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 40 | -------------------------------------------------------------------------------- /playground/components/Accordion.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /playground/utils/getHead.ts: -------------------------------------------------------------------------------- 1 | const description = 'CSS height transition from any to auto and vice versa for Vue and Nuxt.' 2 | const title = 'Vue Collapsed' 3 | 4 | export function getHead() { 5 | return { 6 | title: 'Vue Collapsed - ' + description, 7 | htmlAttrs: { 8 | lang: 'en', 9 | }, 10 | meta: [ 11 | { 12 | hid: 'description', 13 | name: 'description', 14 | content: description, 15 | }, 16 | { 17 | hid: 'og:title', 18 | property: 'og:title', 19 | content: `${title} - ${description}`, 20 | }, 21 | { 22 | hid: 'og:description', 23 | property: 'og:description', 24 | content: description, 25 | }, 26 | { 27 | hid: 'og:url', 28 | property: 'og:url', 29 | content: 'https://notivue.pages.dev', 30 | }, 31 | { 32 | hid: 'twitter:title', 33 | name: 'twitter:title', 34 | content: `${title} - ${description}`, 35 | }, 36 | { 37 | hid: 'twitter:description', 38 | name: 'twitter:description', 39 | content: description, 40 | }, 41 | { 42 | hid: 'twitter:card', 43 | name: 'twitter:card', 44 | content: 'summary_large_image', 45 | }, 46 | ], 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /playground/components/MountUnmount.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | -------------------------------------------------------------------------------- /playground/components/DisplayHide.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /packages/vue-collapsed/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_TRANSITION } from './constants' 2 | 3 | import type { Ref } from 'vue' 4 | 5 | type RefEl = Ref 6 | 7 | export function getComputedHeight(el: RefEl) { 8 | if (!el.value) return 0 9 | return parseFloat(getComputedStyle(el.value).height) 10 | } 11 | 12 | export function getTransitionProp(el: RefEl) { 13 | if (!el.value) return {} 14 | 15 | const { transition } = getComputedStyle(el.value) 16 | 17 | // If transition is not defined via CSS, return the default one referencing the auto duration 18 | if (transition === 'all 0s ease 0s' || transition === 'all') { 19 | /* Since Firefox v124 and Chromium v128, their rendering engines compute 'all' instead of 'all 0s ease 0s' as default transition */ 20 | return { transition: DEFAULT_TRANSITION } 21 | } 22 | 23 | return { transition } 24 | } 25 | 26 | export function isReducedOrDisabled(el: RefEl) { 27 | if (!el.value) return true 28 | 29 | const { transition } = getComputedStyle(el.value) 30 | 31 | return ( 32 | typeof window.requestAnimationFrame === 'undefined' || 33 | window.matchMedia('(prefers-reduced-motion: reduce)').matches || 34 | transition.includes('none') || 35 | transition.includes('height 0s') 36 | ) 37 | } 38 | 39 | /** 40 | * Forked from: 41 | * https://github.com/mui/material-ui/blob/master/packages/mui-material/src/styles/createTransitions.js#L35 42 | */ 43 | export function getAutoDuration(height = 0) { 44 | if (height === 0) return 0 45 | 46 | const constant = height / 36 47 | const autoDuration = Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10) 48 | 49 | return Number.isFinite(autoDuration) ? autoDuration : 0 50 | } 51 | -------------------------------------------------------------------------------- /playground/components/SingleCollapse.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /playground/components/IndividualControl.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /packages/vue-collapsed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-collapsed", 3 | "version": "1.3.4", 4 | "private": false, 5 | "description": "Dynamic CSS height transition from any to auto and vice versa for Vue 3. Accordion ready.", 6 | "keywords": [ 7 | "vue", 8 | "vue-3", 9 | "vue-collapse", 10 | "vue-accordion", 11 | "collapse", 12 | "accordion", 13 | "vue-component" 14 | ], 15 | "homepage": "https://vue-collapsed.pages.dev/", 16 | "bugs": { 17 | "url": "https://github.com/smastrom/vue-collapsed/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/smastrom/vue-collapsed.git" 22 | }, 23 | "license": "MIT", 24 | "author": { 25 | "name": "Simone Mastromattei", 26 | "email": "smastrom@proton.me" 27 | }, 28 | "main": "dist/index.js", 29 | "module": "dist/index.mjs", 30 | "types": "dist/index.d.ts", 31 | "exports": { 32 | ".": { 33 | "import": "./dist/index.mjs", 34 | "require": "./dist/index.js", 35 | "types": "./dist/index.d.ts" 36 | } 37 | }, 38 | "files": [ 39 | "dist/*" 40 | ], 41 | "scripts": { 42 | "watch": "rm -rf dist && vite build --watch", 43 | "build": "cp ../../README.md ../../LICENSE . && vite build", 44 | "postbuild": "pnpm pack", 45 | "test:chrome": "vite build && cypress run --component --browser chrome", 46 | "test:firefox": "vite build && cypress run --component --browser firefox", 47 | "test:gui": "concurrently \"pnpm watch\" \"cypress open --component\"" 48 | }, 49 | "devDependencies": { 50 | "@types/node": "^20.12.5", 51 | "@vitejs/plugin-vue": "^4.6.2", 52 | "concurrently": "^8.2.2", 53 | "cypress": "^13.7.2", 54 | "cypress-wait-frames": "^0.9.8", 55 | "husky": "^9.0.11", 56 | "typescript": "^5.4.4", 57 | "vite": "^4.5.3", 58 | "vite-plugin-dts": "^3.8.1", 59 | "vue": "^3.3.13", 60 | "vue-tsc": "^1.8.27" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /playground/components/WithCallbacks.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 45 | 46 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /playground/components/AdvancedControl.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | 37 | 86 | -------------------------------------------------------------------------------- /playground/components/NestedCollapse.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /playground/utils/fakeData.ts: -------------------------------------------------------------------------------- 1 | export const fakeData = [ 2 | { 3 | title: 'Do you name inanimate objects?', 4 | answer: 5 | "She sat down with her notebook in her hand, her mind wandering to faraway places. She paused and considered all that had happened. It hadn't gone as expected. When the day began she thought it was going to be a bad one, but as she sat recalling the day's events to write them down, she had to admit, it had been a rather marvelous day.", 6 | }, 7 | { 8 | title: 'Is there any place that you have no desire to visit? Why?', 9 | answer: 10 | 'He swung back the fishing pole and cast the line which ell 25 feet away into the river. The lure landed in the perfect spot and he was sure he would soon get a bite. He never expected that the bite would come from behind in the form of a bear.', 11 | }, 12 | { 13 | title: "What's your story about breaking the law?", 14 | answer: 15 | "He walked down the steps from the train station in a bit of a hurry knowing the secrets in the briefcase must be secured as quickly as possible. Bounding down the steps, he heard something behind him and quickly turned in a panic. There was nobody there but a pair of old worn-out shoes were placed neatly on the steps he had just come down. Had he past them without seeing them? It didn't seem possible. He was about to turn and be on his way when a deep chill filled his body.", 16 | }, 17 | { 18 | title: "What's the last thing that you broke and how did it happen?", 19 | answer: 20 | "What were they eating? It didn't taste like anything she had ever eaten before and although she was famished, she didn't dare ask. She knew the answer would be one she didn't want to hear. Things aren't going well at all with mom today. She is just a limp noodle and wants to sleep all the time. I sure hope that things get better soon. There simply wasn't a whole lot he could have done in that particular moment. There simply wasn't a whole lot he could have done in that particular moment. There simply wasn't a whole lot he could have done in that particular moment. There simply wasn't a whole lot he could have done in that particular moment.", 21 | }, 22 | { 23 | title: 'How do you approach life?', 24 | answer: 25 | "Things aren't going well at all with mom today. She is just a limp noodle and wants to sleep all the time. I sure hope that things get better soon.", 26 | }, 27 | { 28 | title: 'Have you ever had or witnessed a drop the mic moment?', 29 | answer: 30 | "There wasn't a whole lot he could do at that moment. He played the situation again and again in his head looking at what he might have done differently to make the situation better. No matter how many times he relived the situation in his head, there was never really a good alternative course of action. There simply wasn't a whole lot he could have done in that particular moment. There wasn't a whole lot he could do at that moment. He played the situation again and again in his head looking at what he might have done differently to make the situation better. No matter how many times he relived the situation in his head, there was never really a good alternative course of action. There simply wasn't a whole lot he could have done in that particular moment.", 31 | }, 32 | ] 33 | -------------------------------------------------------------------------------- /playground/assets/style.css: -------------------------------------------------------------------------------- 1 | @media (max-width: 639px) { 2 | #app { 3 | width: 100%; 4 | } 5 | } 6 | 7 | :root { 8 | --AccentColor: #41b883; 9 | --AccentLightColor: #97e8c4; 10 | --ForegroundColor: #c5c5c5; 11 | --ForegroundColorLight: #929292; 12 | --BackgroundColor: #1a1a1a; 13 | --BackgroundAlternateColor: #242424; 14 | --BackgroundAlphaColor: #41b8833d; 15 | --ArticleBorder: 1px solid var(--ForegroundColorLight); 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | 22 | h1, 23 | h2, 24 | p { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | body { 30 | --Padding: 20px; 31 | margin: 0; 32 | padding: var(--Padding); 33 | font-synthesis: none; 34 | text-rendering: optimizeLegibility; 35 | line-height: 1.75; 36 | -moz-osx-font-smoothing: grayscale; 37 | background: var(--BackgroundColor); 38 | -webkit-tap-highlight-color: transparent; 39 | color: var(--ForegroundColor); 40 | display: flex; 41 | flex-direction: column; 42 | align-items: center; 43 | justify-content: center; 44 | font-family: 45 | Inter, 46 | -apple-system, 47 | BlinkMacSystemFont, 48 | 'Segoe UI', 49 | Roboto, 50 | Oxygen, 51 | Ubuntu, 52 | Cantarell, 53 | 'Fira Sans', 54 | 'Droid Sans', 55 | 'Helvetica Neue', 56 | sans-serif; 57 | } 58 | 59 | main { 60 | width: 100%; 61 | } 62 | 63 | h2 { 64 | font-size: 1.25rem; 65 | margin: 0; 66 | } 67 | 68 | footer { 69 | font-size: 0.865rem; 70 | } 71 | 72 | .AppHeader { 73 | margin-bottom: 70px; 74 | display: flex; 75 | align-items: center; 76 | justify-content: space-between; 77 | } 78 | 79 | .AppHeader h1 { 80 | font-size: 2rem; 81 | } 82 | 83 | .AppHeader svg { 84 | fill: var(--ForegroundColor); 85 | width: 40px; 86 | height: 40px; 87 | } 88 | 89 | .AppHeader a:hover svg { 90 | fill: var(--AccentColor); 91 | } 92 | 93 | .Header { 94 | display: flex; 95 | align-items: center; 96 | justify-content: space-between; 97 | margin-bottom: 30px; 98 | } 99 | 100 | .HeaderTitle { 101 | display: flex; 102 | flex-direction: column; 103 | gap: 0; 104 | margin: 0; 105 | padding: 0; 106 | } 107 | 108 | .HeaderTitle > p { 109 | text-transform: uppercase; 110 | font-size: 0.725rem; 111 | color: var(--AccentColor); 112 | margin: 0; 113 | display: flex; 114 | gap: 0.5rem; 115 | align-items: center; 116 | } 117 | 118 | .ActionButton { 119 | background: none; 120 | cursor: pointer; 121 | margin: 0; 122 | display: flex; 123 | align-items: center; 124 | color: var(--AccentColor); 125 | font-weight: 700; 126 | border: 1px solid var(--AccentColor); 127 | padding: 0.3rem 0.5rem; 128 | border-radius: 7px; 129 | transition: all 150ms ease-out; 130 | } 131 | 132 | .ActionButton:hover { 133 | background: var(--BackgroundAlphaColor); 134 | color: var(--AccentLightColor); 135 | } 136 | 137 | .Panel { 138 | width: 100%; 139 | font-size: 1rem; 140 | color: var(--ForegroundColor); 141 | text-align: left; 142 | font-weight: 600; 143 | } 144 | 145 | .Panel:hover { 146 | color: var(--AccentColor); 147 | } 148 | 149 | .Active { 150 | color: var(--AccentColor); 151 | } 152 | 153 | .CodeLink { 154 | white-space: nowrap; 155 | font-size: 0.925rem; 156 | color: var(--AccentColor); 157 | } 158 | 159 | .CodeLink:hover { 160 | color: var(--ForegroundColor); 161 | } 162 | 163 | .Wrapper { 164 | margin-bottom: 80px; 165 | } 166 | 167 | .Section { 168 | background: var(--BackgroundAlternateColor); 169 | width: 100%; 170 | max-width: 600px; 171 | border-top: var(--ArticleBorder); 172 | margin: 0; 173 | } 174 | 175 | .Section:last-of-type { 176 | border-bottom: var(--ArticleBorder); 177 | } 178 | 179 | .Section button { 180 | width: 100%; 181 | padding: 20px 10px; 182 | border: none; 183 | background: none; 184 | cursor: pointer; 185 | } 186 | 187 | .NestedCollapse { 188 | margin: 0 20px 20px; 189 | border-top: var(--ArticleBorder); 190 | border-bottom: var(--ArticleBorder); 191 | } 192 | 193 | .CollapseContent { 194 | padding: 0 10px 10px; 195 | margin: 0; 196 | color: var(--ForegroundColorLight); 197 | font-size: 1rem; 198 | } 199 | 200 | @media (min-width: 640px) { 201 | main { 202 | width: 600px; 203 | } 204 | 205 | h2 { 206 | font-size: 1.5rem; 207 | } 208 | 209 | footer { 210 | font-size: 0.925rem; 211 | } 212 | 213 | .Panel { 214 | font-size: 1.125rem; 215 | } 216 | 217 | .Section button { 218 | padding: 20px 10px; 219 | } 220 | 221 | .CollapseContent { 222 | padding: 0 20px 20px; 223 | margin: 0; 224 | color: var(--ForegroundColorLight); 225 | } 226 | 227 | .AppHeader h1 { 228 | font-size: 2.5rem; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /packages/vue-collapsed/tests/App.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 131 | 132 | 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![npm](https://img.shields.io/npm/v/vue-collapsed?color=46c119) ![dependencies](https://img.shields.io/badge/dependencies-0-success) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/vue-collapsed?color=success) ![downloads](https://img.shields.io/npm/dm/vue-collapsed) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/smastrom/vue-collapsed/chrome-tests.yml?branch=main&label=tests) 2 | 3 | # Vue Collapsed 4 | 5 | Dynamic CSS height transition from _any to auto_ and vice versa. Accordion ready. 6 | 7 | [Examples and Demo](https://vue-collapsed.pages.dev) - [Stackblitz](https://stackblitz.com/edit/vue-dmjqey?file=src/App.vue) 8 | 9 |
10 | 11 | Check out my other packages for Vue and Nuxt: 12 | 13 | > 🔔 **Notivue** 14 | > _Fully-featured notification system for Vue and Nuxt._ 15 | > [Visit repo ➔ ](https://github.com/smastrom/notivue) 16 | 17 | > 🌀 **Vue Global Loader** 18 | > _Global loaders made easy for Vue and Nuxt._ 19 | > [Visit repo ➔ ](https://github.com/smastrom/vue-global-loader) 20 | 21 | > 👌 **Vue Use Active Scroll** 22 | > _Accurate TOC/sidebar links without compromises._ 23 | > [Visit repo ➔ ](https://github.com/smastrom/vue-use-active-scroll) 24 | 25 | > 🔥 **Vue Use Fixed Header** 26 | > _Turn your boring fixed header into a smart one with three lines of code._ 27 | > [Visit repo ➔ ](https://github.com/smastrom/vue-use-fixed-header) 28 | 29 |
30 | 31 | ## Installation 32 | 33 | ```shell 34 | npm i vue-collapsed 35 | # yarn add vue-collapsed 36 | # pnpm add vue-collapsed 37 | # bun add vue-collapsed 38 | ``` 39 | 40 | ## Props 41 | 42 | | Name | Description | Type | Required | 43 | | ------------ | ---------------------------------------- | ----------------------------- | ------------------ | 44 | | `when` | Value to control collapse | boolean | :white_check_mark: | 45 | | `baseHeight` | Collapsed height in px, defaults to `0`. | number | :x: | 46 | | `as` | Tag to use instead of `div` | _keyof_ HTMLElementTagNameMap | :x: | 47 | 48 | ## Emits 49 | 50 | | Name | Description | Type | 51 | | ------------ | ----------------------------- | ---------- | 52 | | `@expand` | Expand transition start | () => void | 53 | | `@expanded` | Expand transition completed | () => void | 54 | | `@collapse` | Collapse transition start | () => void | 55 | | `@collapsed` | Collapse transition completed | () => void | 56 | 57 | ## Usage 58 | 59 | ```vue 60 | 66 | 67 | 74 | ``` 75 | 76 | ## Automatic transition (default behavior) 77 | 78 | By default, if no height transition is specified the following one is automatically added to the Collapse element: 79 | 80 | `height var(--vc-auto-duration) cubic-bezier(0.33, 1, 0.68, 1)` 81 | 82 | `--vc-auto-duration` is calculated in background and corresponds to an optimal transition duration based on your content height. 83 | 84 | This is the recommended way to use this package unless you want to customize the transition. 85 | 86 | ## Custom transition 87 | 88 | If you prefer to use a custom duration or easing, add a class to Collapse that transitions the `height` property: 89 | 90 | ```vue 91 | 92 |

{{ 'Collapsed '.repeat(100) }}

93 |
94 | ``` 95 | 96 | ```css 97 | .v-collapse { 98 | transition: height 300ms ease-out; 99 | /* or transition: height var(--vc-auto-duration) ease-in-out */ 100 | } 101 | ``` 102 | 103 | ### Multiple transitions 104 | 105 | To transition other properties use the attribute `data-collapse`: 106 | 107 | | Transition | From | Enter | Leave | 108 | | ---------- | ----------- | ------------ | ----------- | 109 | | Expand | `collapsed` | `expanding` | `expanded` | 110 | | Collapse | `expanded` | `collapsing` | `collapsed` | 111 | 112 | ```css 113 | .v-collapse { 114 | --dur-easing: var(--vc-auto-duration) cubic-bezier(0.33, 1, 0.68, 1); 115 | transition: 116 | height var(--dur-easing), 117 | opacity var(--dur-easing); 118 | } 119 | 120 | .v-collapse[data-collapse='expanded'], 121 | .v-collapse[data-collapse='expanding'] { 122 | opacity: 1; 123 | } 124 | 125 | .v-collapse[data-collapse='collapsed'], 126 | .v-collapse[data-collapse='collapsing'] { 127 | opacity: 0; 128 | } 129 | ``` 130 | 131 | Or to use different easings/durations for expand and collapse: 132 | 133 | ```css 134 | .v-collapse[data-collapse='expanding'] { 135 | transition: height 600ms ease-in-out; 136 | } 137 | 138 | .v-collapse[data-collapse='collapsing'] { 139 | transition: height 300ms ease-out; 140 | } 141 | ``` 142 | 143 | Above values can also be accessed using `v-slot`: 144 | 145 | ```vue 146 | 147 | {{ state === 'collapsing' ? 'Collapsing content...' : null }} 148 | 149 | ``` 150 | 151 | ## Example - Accordion 152 | 153 | ```vue 154 | 190 | 191 | 203 | ``` 204 | 205 | ## Accessibility 206 | 207 | `vue-collapsed` automatically detects if users prefer reduced motion and will disable transitions accordingly while keeping the same API behavior (emitting events and post-transition styles). 208 | 209 | You should only add `aria` attributes to the Collapse element according to your use case. 210 | 211 | ```vue 212 | 237 | 238 | 246 | ``` 247 | 248 | ## Manually disabling transitions 249 | 250 | ```vue 251 | 256 | 257 | 262 | ``` 263 | 264 | ## License 265 | 266 | MIT 267 | -------------------------------------------------------------------------------- /packages/vue-collapsed/src/Collapse.vue: -------------------------------------------------------------------------------- 1 | 262 | 263 | 274 | -------------------------------------------------------------------------------- /packages/vue-collapsed/tests/Collapse.cy.ts: -------------------------------------------------------------------------------- 1 | import App from './App.vue' 2 | 3 | import { getRandomIntInclusive, isFirefox } from '../cypress/support/component' 4 | 5 | describe('Collapse', () => { 6 | it('Should be able to set different tag name', () => { 7 | cy.mount(App, { 8 | props: { 9 | initialValue: true, 10 | as: 'section', 11 | }, 12 | }) 13 | 14 | cy.get('#Collapse').should('have.prop', 'tagName', 'SECTION') 15 | }) 16 | 17 | it('Should have correct styles if collapsed on mount', () => { 18 | cy.mount(App) 19 | 20 | cy.get('#Collapse') 21 | .should('have.css', 'display', 'none') 22 | .and('have.css', 'padding', '0px') 23 | .and('have.css', 'border', isFirefox ? '0px rgb(0, 0, 0)' : '0px none rgb(0, 0, 0)') 24 | .and('have.css', 'margin', '0px') 25 | .and('not.have.css', 'transition', '') 26 | .and('not.have.css', 'overflow', 'hidden') 27 | .and((element) => { 28 | /** 29 | * https://github.com/cypress-io/cypress/issues/6309 30 | */ 31 | expect(getComputedStyle(element[0]).height).to.eq('auto') 32 | }) 33 | }) 34 | 35 | it('Should have correct styles if expanded on mount', () => { 36 | cy.mount(App, { 37 | props: { 38 | initialValue: true, 39 | }, 40 | }) 41 | 42 | cy.get('#Collapse') 43 | .should('have.css', 'padding', '0px') 44 | .and('have.css', 'border', isFirefox ? '0px rgb(0, 0, 0)' : '0px none rgb(0, 0, 0)') 45 | .and('have.css', 'margin', '0px') 46 | .and('not.have.css', 'transition', '') 47 | .and('not.have.css', 'display', 'none') 48 | .and('not.have.css', 'overflow', 'hidden') 49 | }) 50 | 51 | it('Should change height if resizing on expanded', () => { 52 | cy.mount(App).viewport('macbook-13') 53 | 54 | cy.get('#TriggerButton').click() 55 | 56 | cy.waitFrames({ 57 | subject: () => cy.get('#Collapse'), 58 | property: 'clientHeight', 59 | frames: 30, 60 | }) 61 | 62 | for (let i = 0; i < 10; i++) { 63 | cy.get('#Collapse').invoke('height').as('desktopHeight') 64 | 65 | cy.get('@desktopHeight').then((desktopHeight) => { 66 | cy.viewport('iphone-x') 67 | 68 | cy.get('#Collapse') 69 | .invoke('height') 70 | .should('be.greaterThan', desktopHeight) 71 | .as('mobileHeight') 72 | }) 73 | 74 | cy.get('@mobileHeight').then((mobileHeight) => { 75 | cy.viewport('macbook-13') 76 | 77 | cy.get('#Collapse').invoke('height').should('be.lessThan', mobileHeight) 78 | }) 79 | } 80 | }) 81 | 82 | it('Should update data-collapse attribute properly', () => { 83 | cy.mount(App) 84 | 85 | const randomIter = getRandomIntInclusive(5, 20) 86 | 87 | for (let i = 0; i < randomIter; i++) { 88 | cy.get('#TriggerButton') 89 | .click() 90 | .get('#Collapse') 91 | .should('have.attr', 'data-collapse', 'expanding') 92 | 93 | cy.waitFrames({ 94 | subject: () => cy.get('#Collapse'), 95 | property: 'clientHeight', 96 | frames: 10, 97 | }) 98 | 99 | cy.get('#Collapse') 100 | .should('have.attr', 'data-collapse', 'expanded') 101 | 102 | .get('#TriggerButton') 103 | .click() 104 | .get('#Collapse') 105 | .should('have.attr', 'data-collapse', 'collapsing') 106 | 107 | cy.waitFrames({ 108 | subject: () => cy.get('#Collapse'), 109 | property: 'clientHeight', 110 | frames: 10, 111 | }) 112 | 113 | .get('#Collapse') 114 | .should('have.attr', 'data-collapse', 'collapsed') 115 | } 116 | }) 117 | 118 | describe('Should execute callbacks properly', () => { 119 | function testCallbacks(isLastActionExpand: boolean) { 120 | const repeatEven = getRandomIntInclusive(10, 20) * 2 121 | for (let i = 0; i < repeatEven; i++) { 122 | cy.get('#TriggerButton').click().wait(50) 123 | } 124 | 125 | cy.get('#CountExpand') 126 | .should('have.text', `${repeatEven / 2}`) 127 | .get('#CountExpanded') 128 | .should('have.text', isLastActionExpand ? '0' : '1') 129 | .get('#CountCollapse') 130 | .should('have.text', `${repeatEven / 2}`) 131 | .get('#CountCollapsed') 132 | .should('have.text', isLastActionExpand ? '1' : '0') 133 | } 134 | 135 | it('Expand as last action', () => { 136 | cy.mount(App) 137 | 138 | testCallbacks(true) 139 | }) 140 | 141 | it('Collapse as last action', () => { 142 | cy.mount(App, { 143 | props: { 144 | initialValue: true, 145 | }, 146 | }) 147 | 148 | testCallbacks(false) 149 | }) 150 | }) 151 | 152 | describe('With baseHeight > 0', () => { 153 | it('Should have correct styles if collapsed on mount', () => { 154 | cy.mount(App, { 155 | props: { 156 | initialValue: false, 157 | baseHeight: 100, 158 | }, 159 | }) 160 | 161 | cy.get('#Collapse') 162 | .should('have.css', 'height', '100px') 163 | .and('have.css', 'overflow', 'hidden') 164 | }) 165 | 166 | it('Should have correct styles if expanded on mount', () => { 167 | cy.mount(App, { 168 | props: { 169 | initialValue: true, 170 | baseHeight: 100, 171 | }, 172 | }) 173 | 174 | cy.get('#Collapse') 175 | .should('have.css', 'padding', '0px') 176 | .and('have.css', 'border', isFirefox ? '0px rgb(0, 0, 0)' : '0px none rgb(0, 0, 0)') 177 | 178 | .and('have.css', 'margin', '0px') 179 | .and('not.have.css', 'transition', '') 180 | .and('not.have.css', 'display', 'none') 181 | .and('not.have.css', 'overflow', 'hidden') 182 | }) 183 | 184 | it('Should collapse to baseHeight', () => { 185 | cy.mount(App, { 186 | props: { 187 | initialValue: true, 188 | baseHeight: 100, 189 | }, 190 | }) 191 | 192 | cy.get('#TriggerButton').click() 193 | cy.get('#Collapse') 194 | .should('have.css', 'height', '100px') 195 | .and('have.css', 'overflow', 'hidden') 196 | }) 197 | 198 | it('Should change height if resizing on expanded', () => { 199 | cy.mount(App, { 200 | props: { 201 | initialValue: false, 202 | baseHeight: 100, 203 | }, 204 | }).viewport('macbook-13') 205 | 206 | cy.get('#TriggerButton').click() 207 | 208 | cy.waitFrames({ 209 | subject: () => cy.get('#Collapse'), 210 | property: 'clientHeight', 211 | frames: 30, 212 | }) 213 | 214 | for (let i = 0; i < 10; i++) { 215 | cy.get('#Collapse').invoke('height').as('desktopHeight') 216 | 217 | cy.get('@desktopHeight').then((desktopHeight) => { 218 | cy.viewport('iphone-x') 219 | 220 | cy.get('#Collapse') 221 | .invoke('height') 222 | .should('be.greaterThan', desktopHeight) 223 | .as('mobileHeight') 224 | }) 225 | 226 | cy.get('@mobileHeight').then((mobileHeight) => { 227 | cy.viewport('macbook-13') 228 | 229 | cy.get('#Collapse').invoke('height').should('be.lessThan', mobileHeight) 230 | }) 231 | } 232 | }) 233 | 234 | it('Should update collapsed height if editing value', () => { 235 | const BASE_HEIGHT = 100 236 | const INCREMENT = 10 237 | 238 | cy.mount(App, { 239 | props: { 240 | initialValue: false, 241 | baseHeight: BASE_HEIGHT, 242 | }, 243 | }) 244 | 245 | const randomClicks = getRandomIntInclusive(5, 25) 246 | for (let i = 1; i < randomClicks + 1; i++) { 247 | cy.get('#BaseHeightIncr') 248 | .click() 249 | .get('#Collapse') 250 | .should('have.css', 'height', `${BASE_HEIGHT + INCREMENT * i}px`) 251 | .and('have.css', 'overflow', 'hidden') 252 | } 253 | 254 | cy.get('#Collapse') 255 | .invoke('height') 256 | .then((height) => { 257 | for (let i = 1; i < randomClicks + 1; i++) { 258 | cy.get('#BaseHeightDecr').click() 259 | cy.get('#Collapse') 260 | .should('have.css', 'height', `${Number(height) - INCREMENT * i}px`) 261 | .and('have.css', 'overflow', 'hidden') 262 | } 263 | }) 264 | }) 265 | 266 | it('Should play transition if was hidden on mount', () => { 267 | cy.mount(App, { 268 | props: { 269 | hiddenOnMount: true, 270 | }, 271 | }) 272 | 273 | cy.wait(2000) // Wait for onMounted effect 274 | 275 | cy.get('#TriggerButton').click() 276 | 277 | const transition = 'height 0.3s cubic-bezier(0.33, 1, 0.68, 1)' 278 | 279 | cy.get('#Collapse').should('have.css', 'transition', transition) 280 | 281 | cy.get('#Collapse').and('have.attr', 'style').and('include', '--vc-auto-duration: 300ms') 282 | }) 283 | }) 284 | }) 285 | --------------------------------------------------------------------------------