├── .changeset ├── README.md └── config.json ├── .github └── workflows │ ├── cd.yaml │ └── ci.yaml ├── .gitignore ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── packages └── vaul-vue │ ├── CHANGELOG.md │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── DrawerContent.vue │ ├── DrawerHandle.vue │ ├── DrawerOverlay.vue │ ├── DrawerRoot.vue │ ├── DrawerRootNested.vue │ ├── browser.ts │ ├── constants.ts │ ├── context.ts │ ├── controls.ts │ ├── helpers.ts │ ├── index.ts │ ├── style.css │ ├── types.ts │ ├── usePositionFixed.ts │ ├── useScaleBackground.ts │ └── useSnapPoints.ts │ ├── tsconfig.app.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── tsconfig.vitest.json │ ├── vite.config.ts │ └── vitest.config.ts ├── playground ├── .gitignore ├── .vscode │ └── extensions.json ├── README.md ├── e2e │ ├── base.spec.ts │ ├── constants.ts │ ├── controlled.spec.ts │ ├── direction.spec.ts │ ├── helpers.ts │ ├── initial-snap.spec.ts │ ├── nested.spec.ts │ ├── no-drag-element.spec.ts │ ├── non-dismissible.spec.ts │ ├── tsconfig.json │ ├── with-handle.spec.ts │ ├── with-scaled-background.spec.ts │ └── without-scaled-background.spec.ts ├── env.d.ts ├── index.html ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── assets │ │ └── style.css │ ├── components │ │ ├── BackgroundTexture.vue │ │ ├── DemoDrawer.vue │ │ └── DrawerContent.vue │ ├── main.ts │ ├── router │ │ └── index.ts │ └── views │ │ ├── HomeView.vue │ │ └── tests │ │ ├── ControlledView.vue │ │ ├── DirectionView.vue │ │ ├── InitialSnapView.vue │ │ ├── NestedDrawerView.vue │ │ ├── NoDragElementView.vue │ │ ├── NonDismissibleView.vue │ │ ├── ScrollableWithInputsView.vue │ │ ├── WithHandleView.vue │ │ ├── WithScaledBackgroundView.vue │ │ ├── WithSnapPointsView.vue │ │ └── WithoutScaledBackgroundView.vue ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "next", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["playground"] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | concurrency: ${{ github.workflow }}-${{ github.ref }} 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repo 13 | uses: actions/checkout@v3 14 | 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v2 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: .nvmrc 22 | cache: pnpm 23 | 24 | - name: Install Dependencies 25 | run: pnpm install 26 | 27 | - name: Copy README from root 28 | run: cp README.md packages/vaul-vue/README.md 29 | 30 | - name: Create Release Pull Request or Publish to npm 31 | id: changesets 32 | uses: changesets/action@v1 33 | with: 34 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 35 | publish: pnpm release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches-ignore: 5 | - main 6 | pull_request: 7 | paths: 8 | - 'packages/**' 9 | 10 | jobs: 11 | tests: 12 | name: Playwright Tests 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | - run: npm install -g pnpm@8.6 20 | - run: pnpm install --no-frozen-lockfile 21 | - run: pnpm build:package 22 | - run: npx playwright install --with-deps 23 | - run: pnpm test || exit 1 24 | - uses: actions/upload-artifact@v4 25 | if: always() 26 | with: 27 | name: playwright-report 28 | path: playground/playwright-report/ 29 | retention-days: 30 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | !.vscode/settings.json 24 | .idea 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | 31 | *.tsbuildinfo 32 | 33 | test-results/ 34 | playwright-report/ 35 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "ms-playwright.playwright", 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.experimental.useFlatConfig": true, 4 | 5 | // Disable the default formatter, use eslint instead 6 | "prettier.enable": false, 7 | "editor.formatOnSave": false, 8 | 9 | // Auto fix 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit", 12 | "source.organizeImports": "never" 13 | }, 14 | 15 | // Silent the stylistic rules in you IDE, but still auto fix them 16 | "eslint.rules.customizations": [ 17 | { "rule": "style/*", "severity": "off" }, 18 | { "rule": "format/*", "severity": "off" }, 19 | { "rule": "*-indent", "severity": "off" }, 20 | { "rule": "*-spacing", "severity": "off" }, 21 | { "rule": "*-spaces", "severity": "off" }, 22 | { "rule": "*-order", "severity": "off" }, 23 | { "rule": "*-dangle", "severity": "off" }, 24 | { "rule": "*-newline", "severity": "off" }, 25 | { "rule": "*quotes", "severity": "off" }, 26 | { "rule": "*semi", "severity": "off" } 27 | ], 28 | 29 | // Enable eslint for all supported languages 30 | "eslint.validate": [ 31 | "javascript", 32 | "javascriptreact", 33 | "typescript", 34 | "typescriptreact", 35 | "vue", 36 | "html", 37 | "markdown", 38 | "json", 39 | "jsonc", 40 | "yaml", 41 | "toml" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 unovue 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 | # Vaul Vue 2 | 3 | Vaul Vue is an unstyled drawer component for Vue that can be used as a Dialog replacement on tablet and mobile devices. 4 | It uses [Reka UI's Dialog primitive](https://www.reka-ui.com/docs/components/dialog) under the hood and is a feature complete port of [Emil Kowalski's Vaul library](https://github.com/emilkowalski/vaul) (built for React). 5 | 6 | ## Installation 7 | 8 | ```bash 9 | pnpm add vaul-vue 10 | ``` 11 | 12 | ```bash 13 | npm install vaul-vue 14 | ``` 15 | 16 | ```bash 17 | yarn add vaul-vue 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```vue 23 | 26 | 27 | 38 | ``` 39 | 40 | ## Credits 41 | 42 | All credits go to these open-source works and resources 43 | 44 | - Major credits go to [Emil Kowalski](https://emilkowal.ski/) for the original [Vaul library](https://github.com/emilkowalski/vaul). 45 | - [Reka UI](https://www.reka-ui.com/) for the Dialog primitive used under the hood. 46 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | vue: true, 5 | typescript: true, 6 | rules: { 7 | 'unused-imports/no-unused-vars': 'off', // off for now 8 | '@typescript-eslint/no-unused-vars': 'off', // off for now 9 | 'node/prefer-global/process': 'off', // off for now 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "version": "0.0.0", 4 | "private": true, 5 | "packageManager": "pnpm@8.15.4", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "lint:fix": "eslint . --fix", 9 | "test": "pnpm -r test", 10 | "release": "pnpm -r release && changeset publish", 11 | "dev:build": "pnpm --filter vaul-vue dev", 12 | "build:package": "pnpm --filter vaul-vue build-only", 13 | "build:playground": "pnpm build:package && pnpm --filter playground build" 14 | }, 15 | "devDependencies": { 16 | "@antfu/eslint-config": "^2.6.4", 17 | "@changesets/cli": "^2.27.1", 18 | "@playwright/test": "^1.50.0", 19 | "@rushstack/eslint-patch": "^1.3.3", 20 | "@vue/eslint-config-typescript": "^12.0.0", 21 | "eslint": "^8.56.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/vaul-vue/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # vaul-vue 2 | 3 | ## 0.4.1 4 | 5 | ### Patch Changes 6 | 7 | - 7cd4bf2: fix body style persist after unmount, clicking overlay not dismissing drawer 8 | 9 | ## 0.4.0 10 | 11 | ### Minor Changes 12 | 13 | - 2c205d4: refactor data attribute from `[vaul-*]` to `[data-vaul-*]` 14 | - 3b225eb: Move styles to .css file to be able to use vaul-drawer with strich CSP-Header 15 | 16 | ### Patch Changes 17 | 18 | - 694cf9d: add support for handle only interaction 19 | - 4fb90c6: fix: don't autofocus within Dialog 20 | - a3ad2ca: fix first drag issue when data-vaul-no-drag declared 21 | 22 | ## 0.3.0 23 | 24 | ### Minor Changes 25 | 26 | - cde0d37: migrate from Radix Vue to Reka UI 27 | 28 | ### Patch Changes 29 | 30 | - cde0d37: chore: update readme 31 | 32 | ## 0.3.0-next.1 33 | 34 | ### Patch Changes 35 | 36 | - 6f906b7: chore: update readme 37 | 38 | ## 0.3.0-next.0 39 | 40 | ### Minor Changes 41 | 42 | - migrate from Radix Vue to Reka UI 43 | 44 | ## 0.2.1 45 | 46 | ### Patch Changes 47 | 48 | - bdb5860: Fixed nested drawer animation issue 49 | - 46a8414: Fix snap points 50 | - 4df119c: adjust snapPoints on window resize 51 | - ae25725: feat: emits animationEnd 52 | 53 | ## 0.2.0 54 | 55 | ### Minor Changes 56 | 57 | - e1cfb98: fix: not restoring position when unmounted 58 | - 76e23aa: feat: Direction 59 | 60 | ## 0.1.3 61 | 62 | ### Patch Changes 63 | 64 | - 2dd8e2c: Allow for Upward Dragging in Scenario Involving Snap Points and Dismissible Prop 65 | - 12ab3e8: feat: expose `open` value 66 | 67 | ## 0.1.2 68 | 69 | ### Patch Changes 70 | 71 | - 49733ea: fix: ssr build issue 72 | 73 | ## 0.1.1 74 | 75 | ### Patch Changes 76 | 77 | - 86568ae: fix: body freezes when using with `radix-vue` dropdown 78 | - 669d0d8: Add no-drag feature 79 | - b3f6ce6: fix manual closing doesn't trigger animation 80 | - e22af4d: remove background on scale exit 81 | 82 | ## 0.1.0 83 | 84 | ### Minor Changes 85 | 86 | - 6e40283: General DX improvements to bring package inline with Vue standards 87 | 88 | ### Patch Changes 89 | 90 | - 771f420: Add readme from root to package release 91 | 92 | ## 0.0.3 93 | 94 | ### Patch Changes 95 | 96 | - a176aec: Initial public release 97 | 98 | ## 0.0.2 99 | 100 | ### Patch Changes 101 | 102 | - 39b749b: Add publish step for package 103 | 104 | ## 0.0.1 105 | 106 | ### Patch Changes 107 | 108 | - 4c95197: Initial MVP of Vaul implementation in Vue 109 | -------------------------------------------------------------------------------- /packages/vaul-vue/README.md: -------------------------------------------------------------------------------- 1 | # Vaul Vue 2 | 3 | Vaul Vue is an unstyled drawer component for Vue that can be used as a Dialog replacement on tablet and mobile devices. 4 | It uses [Reka UI's Dialog primitive](https://www.reka-ui.com/docs/components/dialog) under the hood and is a feature complete port of [Emil Kowalski's Vaul library](https://github.com/emilkowalski/vaul) (built for React). 5 | 6 | ## Installation 7 | 8 | ```bash 9 | pnpm add vaul-vue 10 | ``` 11 | 12 | ```bash 13 | npm install vaul-vue 14 | ``` 15 | 16 | ```bash 17 | yarn add vaul-vue 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```vue 23 | 26 | 27 | 38 | ``` 39 | 40 | ## Credits 41 | 42 | All credits go to these open-source works and resources 43 | 44 | - Major credits go to [Emil Kowalski](https://emilkowal.ski/) for the original [Vaul library](https://github.com/emilkowalski/vaul). 45 | - [Reka UI](https://www.reka-ui.com/) for the Dialog primitive used under the hood. 46 | -------------------------------------------------------------------------------- /packages/vaul-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vaul-vue", 3 | "type": "module", 4 | "version": "0.4.1", 5 | "repository": "https://github.com/Elliot-Alexander/vaul-vue", 6 | "keywords": [ 7 | "vue", 8 | "vue3", 9 | "drawer", 10 | "dialog", 11 | "modal", 12 | "headless" 13 | ], 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.umd.cjs" 18 | } 19 | }, 20 | "main": "./dist/index.umd.cjs", 21 | "module": "./dist/index.js", 22 | "types": "./dist/index.d.ts", 23 | "files": [ 24 | "README.md", 25 | "dist" 26 | ], 27 | "scripts": { 28 | "dev": "vite build -w", 29 | "build": "run-p type-check \"build-only {@}\" --", 30 | "preview": "vite preview", 31 | "test:unit": "vitest", 32 | "build-only": "vite build", 33 | "type-check": "vue-tsc --build --force", 34 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", 35 | "format": "prettier --write src/", 36 | "release": "pnpm run build-only" 37 | }, 38 | "peerDependencies": { 39 | "reka-ui": "^2.0.0", 40 | "vue": "^3.3.0" 41 | }, 42 | "dependencies": { 43 | "@vueuse/core": "^10.8.0", 44 | "reka-ui": "^2.0.0", 45 | "vue": "^3.4.5" 46 | }, 47 | "devDependencies": { 48 | "@rushstack/eslint-patch": "^1.3.3", 49 | "@tsconfig/node18": "^18.2.2", 50 | "@types/jsdom": "^21.1.6", 51 | "@types/node": "^18.19.3", 52 | "@vitejs/plugin-vue": "^4.5.2", 53 | "@vue/eslint-config-typescript": "^12.0.0", 54 | "@vue/test-utils": "^2.4.3", 55 | "@vue/tsconfig": "^0.5.0", 56 | "eslint": "^8.49.0", 57 | "eslint-plugin-vue": "^9.17.0", 58 | "jsdom": "^23.0.1", 59 | "npm-run-all2": "^6.1.1", 60 | "typescript": "~5.3.0", 61 | "vite": "^5.0.10", 62 | "vite-plugin-css-injected-by-js": "^3.3.1", 63 | "vite-plugin-dts": "^3.7.0", 64 | "vitest": "^1.0.4", 65 | "vue-tsc": "^1.8.25" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/DrawerContent.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 96 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/DrawerHandle.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 107 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/DrawerOverlay.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/DrawerRoot.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | 103 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/DrawerRootNested.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 41 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/browser.ts: -------------------------------------------------------------------------------- 1 | export function isMobileFirefox(): boolean | undefined { 2 | const userAgent = navigator.userAgent 3 | return ( 4 | typeof window !== 'undefined' 5 | && ((/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox 6 | || /FxiOS/.test(userAgent)) // iOS Firefox 7 | ) 8 | } 9 | 10 | export function isMac(): boolean | undefined { 11 | return testPlatform(/^Mac/) 12 | } 13 | 14 | export function isIPhone(): boolean | undefined { 15 | return testPlatform(/^iPhone/) 16 | } 17 | 18 | export function isSafari(): boolean | undefined { 19 | // eslint-disable-next-line regexp/no-unused-capturing-group 20 | return /^((?!chrome|android).)*safari/i.test(navigator.userAgent) 21 | } 22 | 23 | export function isIPad(): boolean | undefined { 24 | return ( 25 | testPlatform(/^iPad/) 26 | // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support. 27 | || (isMac() && navigator.maxTouchPoints > 1) 28 | ) 29 | } 30 | 31 | export function isIOS(): boolean | undefined { 32 | return isIPhone() || isIPad() 33 | } 34 | 35 | export function testPlatform(re: RegExp): boolean | undefined { 36 | return typeof window !== 'undefined' && window.navigator != null ? re.test(window.navigator.platform) : undefined 37 | } 38 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TRANSITIONS = { 2 | DURATION: 0.5, 3 | EASE: [0.32, 0.72, 0, 1], 4 | } 5 | 6 | export const VELOCITY_THRESHOLD = 0.4 7 | 8 | export const CLOSE_THRESHOLD = 0.25 9 | 10 | export const SCROLL_LOCK_TIMEOUT = 100 11 | 12 | export const BORDER_RADIUS = 8 13 | 14 | export const NESTED_DISPLACEMENT = 16 15 | 16 | export const WINDOW_TOP_OFFSET = 26 17 | 18 | export const DRAG_CLASS = 'vaul-dragging' 19 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/context.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentPublicInstance, Ref } from 'vue' 2 | import { createContext } from 'reka-ui' 3 | import type { DrawerDirection } from './types' 4 | 5 | export interface DrawerRootContext { 6 | open: Ref 7 | isOpen: Ref 8 | modal: Ref 9 | hasBeenOpened: Ref 10 | drawerRef: Ref 11 | overlayRef: Ref 12 | handleRef: Ref 13 | isDragging: Ref 14 | dragStartTime: Ref 15 | isAllowedToDrag: Ref 16 | snapPoints: Ref<(number | string)[] | undefined> 17 | hasSnapPoints: Ref 18 | keyboardIsOpen: Ref 19 | activeSnapPoint: Ref 20 | pointerStart: Ref 21 | dismissible: Ref 22 | drawerHeightRef: Ref 23 | snapPointsOffset: Ref 24 | direction: Ref 25 | onPress: (event: PointerEvent) => void 26 | onDrag: (event: PointerEvent) => void 27 | onRelease: (event: PointerEvent) => void 28 | closeDrawer: () => void 29 | shouldFade: Ref 30 | fadeFromIndex: Ref 31 | shouldScaleBackground: Ref 32 | setBackgroundColorOnScale: Ref 33 | onNestedDrag: (percentageDragged: number) => void 34 | onNestedRelease: (o: boolean) => void 35 | onNestedOpenChange: (o: boolean) => void 36 | emitClose: () => void 37 | emitDrag: (percentageDragged: number) => void 38 | emitRelease: (open: boolean) => void 39 | emitOpenChange: (o: boolean) => void 40 | nested: Ref 41 | handleOnly: Ref 42 | noBodyStyles: Ref 43 | } 44 | 45 | export const [injectDrawerRootContext, provideDrawerRootContext] 46 | = createContext('DrawerRoot') 47 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/controls.ts: -------------------------------------------------------------------------------- 1 | import { computed, onUnmounted, ref, watch, watchEffect } from 'vue' 2 | import type { ComponentPublicInstance, Ref } from 'vue' 3 | import { isClient } from '@vueuse/core' 4 | import { dampenValue, getTranslate, isVertical, reset, set } from './helpers' 5 | import { BORDER_RADIUS, DRAG_CLASS, NESTED_DISPLACEMENT, TRANSITIONS, VELOCITY_THRESHOLD, WINDOW_TOP_OFFSET } from './constants' 6 | import { useSnapPoints } from './useSnapPoints' 7 | import { usePositionFixed } from './usePositionFixed' 8 | import type { DrawerRootContext } from './context' 9 | import type { DrawerDirection } from './types' 10 | 11 | export interface WithoutFadeFromProps { 12 | /** 13 | * Array of numbers from 0 to 100 that corresponds to % of the screen a given snap point should take up. 14 | * Should go from least visible. Example `[0.2, 0.5, 0.8]`. 15 | * You can also use px values, which doesn't take screen height into account. 16 | */ 17 | snapPoints?: (number | string)[] 18 | /** 19 | * Index of a `snapPoint` from which the overlay fade should be applied. Defaults to the last snap point. 20 | */ 21 | fadeFromIndex?: never 22 | } 23 | 24 | export type DrawerRootProps = { 25 | activeSnapPoint?: number | string | null 26 | /** 27 | * Number between 0 and 1 that determines when the drawer should be closed. 28 | * Example: threshold of 0.5 would close the drawer if the user swiped for 50% of the height of the drawer or more. 29 | * @default 0.25 30 | */ 31 | closeThreshold?: number 32 | shouldScaleBackground?: boolean 33 | /** 34 | * When `false` we don't change body's background color when the drawer is open. 35 | * @default true 36 | */ 37 | setBackgroundColorOnScale?: boolean 38 | /** 39 | * Duration for which the drawer is not draggable after scrolling content inside of the drawer. 40 | * @default 500ms 41 | */ 42 | scrollLockTimeout?: number 43 | /** 44 | * When `true`, don't move the drawer upwards if there's space, but rather only change it's height so it's fully scrollable when the keyboard is open 45 | */ 46 | fixed?: boolean 47 | /** 48 | * When `false` dragging, clicking outside, pressing esc, etc. will not close the drawer. 49 | * Use this in combination with the `open` prop, otherwise you won't be able to open/close the drawer. 50 | * @default true 51 | */ 52 | dismissible?: boolean 53 | /** 54 | * When `false` it allows to interact with elements outside of the drawer without closing it. 55 | * @default true 56 | */ 57 | modal?: boolean 58 | open?: boolean 59 | /** 60 | * Opened by default, skips initial enter animation. Still reacts to `open` state changes 61 | * @default false 62 | */ 63 | defaultOpen?: boolean 64 | nested?: boolean 65 | /** 66 | * Direction of the drawer. Can be `top` or `bottom`, `left`, `right`. 67 | * @default 'bottom' 68 | */ 69 | direction?: DrawerDirection 70 | /** 71 | * When `true` the `body` doesn't get any styles assigned from Vaul 72 | */ 73 | noBodyStyles?: boolean 74 | /** 75 | * When `true` only allows the drawer to be dragged by the `` component. 76 | * @default false 77 | */ 78 | handleOnly?: boolean 79 | preventScrollRestoration?: boolean 80 | } & WithoutFadeFromProps 81 | 82 | export interface UseDrawerProps { 83 | open: Ref 84 | snapPoints: Ref<(number | string)[] | undefined> 85 | dismissible: Ref 86 | nested: Ref 87 | fixed: Ref 88 | modal: Ref 89 | shouldScaleBackground: Ref 90 | setBackgroundColorOnScale: Ref 91 | activeSnapPoint: Ref 92 | fadeFromIndex: Ref 93 | closeThreshold: Ref 94 | scrollLockTimeout: Ref 95 | direction: Ref 96 | noBodyStyles: Ref 97 | preventScrollRestoration: Ref 98 | handleOnly: Ref 99 | } 100 | 101 | export interface DrawerRootEmits { 102 | (e: 'drag', percentageDragged: number): void 103 | (e: 'release', open: boolean): void 104 | (e: 'close'): void 105 | (e: 'update:open', open: boolean): void 106 | (e: 'update:activeSnapPoint', val: string | number): void 107 | /** 108 | * Gets triggered after the open or close animation ends, it receives an `open` argument with the `open` state of the drawer by the time the function was triggered. 109 | * Useful to revert any state changes for example. 110 | */ 111 | (e: 'animationEnd', open: boolean): void 112 | } 113 | 114 | export interface DialogEmitHandlers { 115 | emitDrag: (percentageDragged: number) => void 116 | emitRelease: (open: boolean) => void 117 | emitClose: () => void 118 | emitOpenChange: (open: boolean) => void 119 | } 120 | 121 | export interface Drawer { 122 | isOpen: Ref 123 | hasBeenOpened: Ref 124 | drawerRef: Ref 125 | overlayRef: Ref 126 | isDragging: Ref 127 | dragStartTime: Ref 128 | isAllowedToDrag: Ref 129 | snapPoints: Ref<(number | string)[] | undefined> 130 | activeSnapPoint: Ref 131 | pointerStart: Ref 132 | dismissible: Ref 133 | drawerHeightRef: Ref 134 | snapPointsOffset: Ref 135 | onPress: (event: PointerEvent) => void 136 | onDrag: (event: PointerEvent) => void 137 | onRelease: (event: PointerEvent) => void 138 | closeDrawer: () => void 139 | } 140 | 141 | export interface DrawerHandleProps { 142 | preventCycle?: boolean 143 | } 144 | 145 | function usePropOrDefaultRef(prop: Ref | undefined, defaultRef: Ref): Ref { 146 | return prop && !!prop.value ? (prop as Ref) : defaultRef 147 | } 148 | 149 | export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRootContext { 150 | const { 151 | emitDrag, 152 | emitRelease, 153 | emitClose, 154 | emitOpenChange, 155 | open, 156 | dismissible, 157 | nested, 158 | fixed, 159 | modal, 160 | shouldScaleBackground, 161 | setBackgroundColorOnScale, 162 | scrollLockTimeout, 163 | closeThreshold, 164 | activeSnapPoint, 165 | fadeFromIndex, 166 | direction, 167 | noBodyStyles, 168 | handleOnly, 169 | preventScrollRestoration, 170 | } = props 171 | 172 | const isOpen = ref(open.value ?? false) 173 | const hasBeenOpened = ref(false) 174 | const isDragging = ref(false) 175 | const justReleased = ref(false) 176 | 177 | const overlayRef = ref(null) 178 | 179 | const openTime = ref(null) 180 | const dragStartTime = ref(null) 181 | const dragEndTime = ref(null) 182 | const lastTimeDragPrevented = ref(null) 183 | const isAllowedToDrag = ref(false) 184 | 185 | const nestedOpenChangeTimer = ref(null) 186 | 187 | const pointerStart = ref(0) 188 | const keyboardIsOpen = ref(false) 189 | 190 | const previousDiffFromInitial = ref(0) 191 | 192 | const drawerRef = ref(null) 193 | const initialDrawerHeight = ref(0) 194 | const drawerHeightRef = computed(() => drawerRef.value?.$el.getBoundingClientRect().height || 0) 195 | 196 | const snapPoints = usePropOrDefaultRef( 197 | props.snapPoints, 198 | ref<(number | string)[] | undefined>(undefined), 199 | ) 200 | 201 | const hasSnapPoints = computed(() => snapPoints && (snapPoints.value?.length ?? 0) > 0) 202 | 203 | const handleRef = ref(null) 204 | 205 | // const onCloseProp = ref<(() => void) | undefined>(undefined) 206 | // const onOpenChangeProp = ref<((open: boolean) => void) | undefined>(undefined) 207 | // const onDragProp = ref<((event: PointerEvent, percentageDragged: number) => void) | undefined>( 208 | // undefined 209 | // ) 210 | // const onReleaseProp = ref<((event: PointerEvent, open: boolean) => void) | undefined>(undefined) 211 | 212 | // const fadeFromIndex = ref( 213 | // props.fadeFromIndex ?? (snapPoints.value && snapPoints.value.length - 1) 214 | // ) 215 | 216 | const { 217 | activeSnapPointIndex, 218 | onRelease: onReleaseSnapPoints, 219 | snapPointsOffset, 220 | onDrag: onDragSnapPoints, 221 | shouldFade, 222 | getPercentageDragged: getSnapPointsPercentageDragged, 223 | } = useSnapPoints({ 224 | snapPoints, 225 | activeSnapPoint, 226 | drawerRef, 227 | fadeFromIndex, 228 | overlayRef, 229 | onSnapPointChange, 230 | direction, 231 | }) 232 | 233 | function onSnapPointChange(activeSnapPointIndex: number, snapPointsOffset: number[]) { 234 | // Change openTime ref when we reach the last snap point to prevent dragging for 500ms incase it's scrollable. 235 | if (snapPoints.value && activeSnapPointIndex === snapPointsOffset.length - 1) 236 | openTime.value = new Date() 237 | } 238 | 239 | const { restorePositionSetting } = usePositionFixed({ 240 | isOpen, 241 | modal, 242 | nested, 243 | hasBeenOpened, 244 | noBodyStyles, 245 | preventScrollRestoration, 246 | }) 247 | 248 | function getScale() { 249 | return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth 250 | } 251 | 252 | function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean) { 253 | if (!el) 254 | return false 255 | let element = el as HTMLElement 256 | const highlightedText = window.getSelection()?.toString() 257 | const swipeAmount = drawerRef.value ? getTranslate(drawerRef.value.$el, direction.value) : null 258 | const date = new Date() 259 | 260 | if (element.hasAttribute('data-vaul-no-drag') || element.closest('[data-vaul-no-drag]')) 261 | return false 262 | 263 | if (direction.value === 'right' || direction.value === 'left') 264 | return true 265 | 266 | // Allow scrolling when animating 267 | if (openTime.value && date.getTime() - openTime.value.getTime() < 500) 268 | return false 269 | 270 | if (swipeAmount !== null) { 271 | if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0) 272 | return true 273 | } 274 | 275 | // Don't drag if there's highlighted text 276 | if (highlightedText && highlightedText.length > 0) 277 | return false 278 | 279 | // Disallow dragging if drawer was scrolled within `scrollLockTimeout` 280 | if ( 281 | lastTimeDragPrevented.value 282 | && date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value 283 | && swipeAmount === 0 284 | ) { 285 | lastTimeDragPrevented.value = date 286 | return false 287 | } 288 | 289 | if (isDraggingInDirection) { 290 | lastTimeDragPrevented.value = date 291 | 292 | // We are dragging down so we should allow scrolling 293 | return false 294 | } 295 | 296 | // Keep climbing up the DOM tree as long as there's a parent 297 | while (element) { 298 | // Check if the element is scrollable 299 | if (element.scrollHeight > element.clientHeight) { 300 | if (element.scrollTop !== 0) { 301 | lastTimeDragPrevented.value = new Date() 302 | 303 | // The element is scrollable and not scrolled to the top, so don't drag 304 | return false 305 | } 306 | 307 | if (element.getAttribute('role') === 'dialog') 308 | return true 309 | } 310 | 311 | // Move up to the parent element 312 | element = element.parentNode as HTMLElement 313 | } 314 | 315 | // No scrollable parents not scrolled to the top found, so drag 316 | return true 317 | } 318 | 319 | function onPress(event: PointerEvent) { 320 | if (!dismissible.value && !snapPoints.value) 321 | return 322 | if (drawerRef.value && !drawerRef.value.$el.contains(event.target as Node)) 323 | return 324 | isDragging.value = true 325 | dragStartTime.value = new Date() 326 | 327 | ;(event.target as HTMLElement).setPointerCapture(event.pointerId) 328 | pointerStart.value = isVertical(direction.value) ? event.clientY : event.clientX 329 | } 330 | 331 | function onDrag(event: PointerEvent) { 332 | if (!drawerRef.value) 333 | return 334 | 335 | // We need to know how much of the drawer has been dragged in percentages so that we can transform background accordingly 336 | if (isDragging.value) { 337 | const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1 338 | const draggedDistance 339 | = (pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX)) * directionMultiplier 340 | const isDraggingInDirection = draggedDistance > 0 341 | 342 | // Pre condition for disallowing dragging in the close direction. 343 | const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection 344 | 345 | // Disallow dragging down to close when first snap point is the active one and dismissible prop is set to false. 346 | if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0) 347 | return 348 | 349 | // We need to capture last time when drag with scroll was triggered and have a timeout between 350 | const absDraggedDistance = Math.abs(draggedDistance) 351 | const wrapper 352 | = (document.querySelector('[data-vaul-drawer-wrapper]') as HTMLElement) 353 | || (document.querySelector('[vaul-drawer-wrapper]') as HTMLElement) 354 | 355 | // Calculate the percentage dragged, where 1 is the closed position 356 | let percentageDragged = absDraggedDistance / drawerHeightRef.value 357 | const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection) 358 | 359 | if (snapPointPercentageDragged !== null) 360 | percentageDragged = snapPointPercentageDragged 361 | 362 | // Disallow close dragging beyond the smallest snap point. 363 | if (noCloseSnapPointsPreCondition && percentageDragged >= 1) 364 | return 365 | 366 | if (!isAllowedToDrag.value && !shouldDrag(event.target, isDraggingInDirection)) 367 | return 368 | drawerRef?.value?.$el.classList.add(DRAG_CLASS) 369 | // If shouldDrag gave true once after pressing down on the drawer, we set isAllowedToDrag to true and it will remain true until we let go, there's no reason to disable dragging mid way, ever, and that's the solution to it 370 | isAllowedToDrag.value = true 371 | set(drawerRef.value?.$el, { 372 | transition: 'none', 373 | }) 374 | 375 | set(overlayRef.value?.$el, { 376 | transition: 'none', 377 | }) 378 | 379 | if (snapPoints.value) 380 | onDragSnapPoints({ draggedDistance }) 381 | 382 | // Run this only if snapPoints are not defined or if we are at the last snap point (highest one) 383 | if (isDraggingInDirection && !snapPoints.value) { 384 | const dampenedDraggedDistance = dampenValue(draggedDistance) 385 | 386 | const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * directionMultiplier 387 | set(drawerRef.value?.$el, { 388 | transform: isVertical(direction.value) 389 | ? `translate3d(0, ${translateValue}px, 0)` 390 | : `translate3d(${translateValue}px, 0, 0)`, 391 | }) 392 | return 393 | } 394 | 395 | const opacityValue = 1 - percentageDragged 396 | 397 | if ( 398 | shouldFade.value 399 | || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1) 400 | ) { 401 | emitDrag(percentageDragged) 402 | 403 | set( 404 | overlayRef.value?.$el, 405 | { 406 | opacity: `${opacityValue}`, 407 | transition: 'none', 408 | }, 409 | true, 410 | ) 411 | } 412 | 413 | if (wrapper && overlayRef.value && shouldScaleBackground.value) { 414 | // Calculate percentageDragged as a fraction (0 to 1) 415 | const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1) 416 | const borderRadiusValue = 8 - percentageDragged * 8 417 | 418 | const translateValue = Math.max(0, 14 - percentageDragged * 14) 419 | 420 | set( 421 | wrapper, 422 | { 423 | borderRadius: `${borderRadiusValue}px`, 424 | transform: isVertical(direction.value) 425 | ? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)` 426 | : `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`, 427 | transition: 'none', 428 | }, 429 | true, 430 | ) 431 | } 432 | 433 | if (!snapPoints.value) { 434 | const translateValue = absDraggedDistance * directionMultiplier 435 | 436 | set(drawerRef.value?.$el, { 437 | transform: isVertical(direction.value) 438 | ? `translate3d(0, ${translateValue}px, 0)` 439 | : `translate3d(${translateValue}px, 0, 0)`, 440 | }) 441 | } 442 | } 443 | } 444 | 445 | function resetDrawer() { 446 | if (!drawerRef.value) 447 | return 448 | const wrapper 449 | = (document.querySelector('[data-vaul-drawer-wrapper]') as HTMLElement) 450 | || (document.querySelector('[vaul-drawer-wrapper]') as HTMLElement) 451 | 452 | const currentSwipeAmount = getTranslate(drawerRef.value.$el, direction.value) 453 | 454 | set(drawerRef.value.$el, { 455 | transform: 'translate3d(0, 0, 0)', 456 | transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 457 | }) 458 | 459 | set(overlayRef.value?.$el, { 460 | transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 461 | opacity: '1', 462 | }) 463 | 464 | // Don't reset background if swiped upwards 465 | if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && isOpen.value) { 466 | set( 467 | wrapper, 468 | { 469 | borderRadius: `${BORDER_RADIUS}px`, 470 | overflow: 'hidden', 471 | ...(isVertical(direction.value) 472 | ? { 473 | transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`, 474 | transformOrigin: 'top', 475 | } 476 | : { 477 | transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`, 478 | transformOrigin: 'left', 479 | }), 480 | transitionProperty: 'transform, border-radius', 481 | transitionDuration: `${TRANSITIONS.DURATION}s`, 482 | transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 483 | }, 484 | true, 485 | ) 486 | } 487 | } 488 | 489 | function closeDrawer(fromWithin?: boolean) { 490 | if (!drawerRef.value) 491 | return 492 | 493 | emitClose() 494 | if (!fromWithin) 495 | isOpen.value = false 496 | 497 | window.setTimeout(() => { 498 | if (snapPoints.value) 499 | activeSnapPoint.value = snapPoints.value[0] 500 | }, TRANSITIONS.DURATION * 1000) // seconds to ms 501 | } 502 | 503 | watchEffect(() => { 504 | if (!isOpen.value && shouldScaleBackground.value && isClient) { 505 | // Can't use `onAnimationEnd` as the component will be invisible by then 506 | const id = setTimeout(() => { 507 | reset(document.body) 508 | }, 200) 509 | 510 | return () => clearTimeout(id) 511 | } 512 | }) 513 | 514 | watch(open, () => { 515 | // reflect controlled `open` state 516 | isOpen.value = open.value 517 | if (!open.value) { 518 | closeDrawer() 519 | } 520 | }) 521 | 522 | function onRelease(event: PointerEvent) { 523 | if (!isDragging.value || !drawerRef.value) 524 | return 525 | 526 | drawerRef.value.$el.classList.remove(DRAG_CLASS) 527 | isAllowedToDrag.value = false 528 | isDragging.value = false 529 | dragEndTime.value = new Date() 530 | const swipeAmount = getTranslate(drawerRef.value.$el, direction.value) 531 | 532 | if (!shouldDrag(event.target, false) || !swipeAmount || Number.isNaN(swipeAmount)) 533 | return 534 | 535 | if (dragStartTime.value === null) 536 | return 537 | 538 | const timeTaken = dragEndTime.value.getTime() - dragStartTime.value.getTime() 539 | const distMoved = pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX) 540 | const velocity = Math.abs(distMoved) / timeTaken 541 | 542 | if (velocity > 0.05) { 543 | // `justReleased` is needed to prevent the drawer from focusing on an input when the drag ends, as it's not the intent most of the time. 544 | justReleased.value = true 545 | 546 | window.setTimeout(() => { 547 | justReleased.value = false 548 | }, 200) 549 | } 550 | 551 | if (snapPoints.value) { 552 | const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1 553 | 554 | onReleaseSnapPoints({ 555 | draggedDistance: distMoved * directionMultiplier, 556 | closeDrawer, 557 | velocity, 558 | dismissible: dismissible.value, 559 | }) 560 | emitRelease(true) 561 | return 562 | } 563 | 564 | // Moved upwards, don't do anything 565 | if (direction.value === 'bottom' || direction.value === 'right' ? distMoved > 0 : distMoved < 0) { 566 | resetDrawer() 567 | emitRelease(true) 568 | return 569 | } 570 | 571 | if (velocity > VELOCITY_THRESHOLD) { 572 | closeDrawer() 573 | emitRelease(false) 574 | return 575 | } 576 | 577 | const visibleDrawerHeight = Math.min( 578 | drawerRef.value.$el.getBoundingClientRect().height ?? 0, 579 | window.innerHeight, 580 | ) 581 | 582 | if (swipeAmount >= visibleDrawerHeight * closeThreshold.value) { 583 | closeDrawer() 584 | emitRelease(false) 585 | return 586 | } 587 | 588 | emitRelease(true) 589 | resetDrawer() 590 | } 591 | 592 | watch(isOpen, (o) => { 593 | if (o) { 594 | openTime.value = new Date() 595 | } 596 | emitOpenChange(o) 597 | }, { immediate: true }) 598 | 599 | function onNestedOpenChange(o: boolean) { 600 | const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1 601 | const y = o ? -NESTED_DISPLACEMENT : 0 602 | 603 | if (nestedOpenChangeTimer.value) 604 | window.clearTimeout(nestedOpenChangeTimer.value) 605 | 606 | set(drawerRef.value?.$el, { 607 | transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 608 | transform: `scale(${scale}) translate3d(0, ${y}px, 0)`, 609 | }) 610 | 611 | if (!o && drawerRef.value?.$el) { 612 | nestedOpenChangeTimer.value = window.setTimeout(() => { 613 | const translateValue = getTranslate(drawerRef.value?.$el, direction.value) 614 | set(drawerRef.value?.$el, { 615 | transition: 'none', 616 | transform: isVertical(direction.value) 617 | ? `translate3d(0, ${translateValue}px, 0)` 618 | : `translate3d(${translateValue}px, 0, 0)`, 619 | }) 620 | }, 500) 621 | } 622 | } 623 | 624 | function onNestedDrag(percentageDragged: number) { 625 | if (percentageDragged < 0) 626 | return 627 | 628 | const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth 629 | const initialScale = (initialDim - NESTED_DISPLACEMENT) / initialDim 630 | const newScale = initialScale + percentageDragged * (1 - initialScale) 631 | const newTranslate = -NESTED_DISPLACEMENT + percentageDragged * NESTED_DISPLACEMENT 632 | 633 | set(drawerRef.value?.$el, { 634 | transform: isVertical(direction.value) 635 | ? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)` 636 | : `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`, 637 | transition: 'none', 638 | }) 639 | } 640 | 641 | function onNestedRelease(o: boolean) { 642 | const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth 643 | const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1 644 | const translate = o ? -NESTED_DISPLACEMENT : 0 645 | 646 | if (o) { 647 | set(drawerRef.value?.$el, { 648 | transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 649 | transform: isVertical(direction.value) 650 | ? `scale(${scale}) translate3d(0, ${translate}px, 0)` 651 | : `scale(${scale}) translate3d(${translate}px, 0, 0)`, 652 | }) 653 | } 654 | } 655 | 656 | return { 657 | open, 658 | isOpen, 659 | modal, 660 | keyboardIsOpen, 661 | hasBeenOpened, 662 | drawerRef, 663 | drawerHeightRef, 664 | overlayRef, 665 | handleRef, 666 | isDragging, 667 | dragStartTime, 668 | isAllowedToDrag, 669 | snapPoints, 670 | activeSnapPoint, 671 | hasSnapPoints, 672 | pointerStart, 673 | dismissible, 674 | snapPointsOffset, 675 | direction, 676 | shouldFade, 677 | fadeFromIndex, 678 | shouldScaleBackground, 679 | setBackgroundColorOnScale, 680 | onPress, 681 | onDrag, 682 | onRelease, 683 | closeDrawer, 684 | onNestedDrag, 685 | onNestedRelease, 686 | onNestedOpenChange, 687 | emitClose, 688 | emitDrag, 689 | emitRelease, 690 | emitOpenChange, 691 | nested, 692 | handleOnly, 693 | noBodyStyles, 694 | } 695 | } 696 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { AnyFunction, DrawerDirection } from './types' 2 | 3 | interface Style { 4 | [key: string]: string 5 | } 6 | 7 | const cache = new WeakMap() 8 | 9 | export function isInView(el: HTMLElement): boolean { 10 | const rect = el.getBoundingClientRect() 11 | 12 | if (!window.visualViewport) 13 | return false 14 | 15 | return ( 16 | rect.top >= 0 17 | && rect.left >= 0 18 | // Need + 40 for safari detection 19 | && rect.bottom <= window.visualViewport.height - 40 20 | && rect.right <= window.visualViewport.width 21 | ) 22 | } 23 | 24 | export function set(el?: Element | HTMLElement | null, styles?: Style, ignoreCache = false) { 25 | if (!el || !(el instanceof HTMLElement) || !styles) 26 | return 27 | const originalStyles: Style = {} 28 | 29 | Object.entries(styles).forEach(([key, value]: [string, string]) => { 30 | if (key.startsWith('--')) { 31 | el.style.setProperty(key, value) 32 | return 33 | } 34 | 35 | originalStyles[key] = (el.style as any)[key]; 36 | (el.style as any)[key] = value 37 | }) 38 | 39 | if (ignoreCache) 40 | return 41 | 42 | cache.set(el, originalStyles) 43 | } 44 | 45 | export function reset(el: Element | HTMLElement | null, prop?: string) { 46 | if (!el || !(el instanceof HTMLElement)) 47 | return 48 | const originalStyles = cache.get(el) 49 | 50 | if (!originalStyles) 51 | return 52 | 53 | if (prop) { 54 | ; (el.style as any)[prop] = originalStyles[prop] 55 | } 56 | else { 57 | Object.entries(originalStyles).forEach(([key, value]) => { 58 | ; (el.style as any)[key] = value 59 | }) 60 | } 61 | } 62 | 63 | export function getTranslate(element: HTMLElement, direction: DrawerDirection): number | null { 64 | const style = window.getComputedStyle(element) 65 | const transform 66 | // @ts-expect-error some custom style only exist in certain browser 67 | = style.transform || style.webkitTransform || style.mozTransform 68 | let mat = transform.match(/^matrix3d\((.+)\)$/) 69 | if (mat) { 70 | // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d 71 | return Number.parseFloat(mat[1].split(', ')[isVertical(direction) ? 13 : 12]) 72 | } 73 | // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix 74 | mat = transform.match(/^matrix\((.+)\)$/) 75 | return mat ? Number.parseFloat(mat[1].split(', ')[isVertical(direction) ? 5 : 4]) : null 76 | } 77 | 78 | export function dampenValue(v: number) { 79 | return 8 * (Math.log(v + 1) - 2) 80 | } 81 | 82 | export function isVertical(direction: DrawerDirection) { 83 | switch (direction) { 84 | case 'top': 85 | case 'bottom': 86 | return true 87 | case 'left': 88 | case 'right': 89 | return false 90 | default: 91 | return direction satisfies never 92 | } 93 | } 94 | 95 | export function assignStyle(element: HTMLElement | null | undefined, style: Partial) { 96 | if (!element) 97 | return () => {} 98 | 99 | const prevStyle = element.style.cssText 100 | Object.assign(element.style, style) 101 | 102 | return () => { 103 | element.style.cssText = prevStyle 104 | } 105 | } 106 | 107 | /** 108 | * Receives functions as arguments and returns a new function that calls all. 109 | */ 110 | export function chain(...fns: T[]) { 111 | return (...args: T extends AnyFunction ? Parameters : never) => { 112 | for (const fn of fns) { 113 | if (typeof fn === 'function') { 114 | // eslint-disable-next-line ts/ban-ts-comment 115 | // @ts-ignore 116 | fn(...args) 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/index.ts: -------------------------------------------------------------------------------- 1 | import DrawerRoot from './DrawerRoot.vue' 2 | import DrawerRootNested from './DrawerRootNested.vue' 3 | import DrawerOverlay from './DrawerOverlay.vue' 4 | import DrawerContent from './DrawerContent.vue' 5 | import DrawerHandle from './DrawerHandle.vue' 6 | 7 | export type { 8 | DrawerRootEmits, 9 | DrawerRootProps, 10 | } from './controls' 11 | 12 | export type { 13 | SnapPoint, 14 | DrawerDirection, 15 | } from './types' 16 | 17 | export { 18 | DrawerRoot, 19 | DrawerRootNested, 20 | DrawerOverlay, 21 | DrawerContent, 22 | DrawerHandle, 23 | } 24 | 25 | export { 26 | DialogClose as DrawerClose, 27 | type DialogCloseProps as DrawerCloseProps, 28 | 29 | DialogDescription as DrawerDescription, 30 | type DialogDescriptionProps as DrawerDescriptionProps, 31 | 32 | DialogPortal as DrawerPortal, 33 | type DialogPortalProps as DrawerPortalProps, 34 | 35 | DialogTitle as DrawerTitle, 36 | type DialogTitleProps as DrawerTitleProps, 37 | 38 | DialogTrigger as DrawerTrigger, 39 | type DialogTriggerProps as DrawerTriggerProps, 40 | } from 'reka-ui' 41 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/style.css: -------------------------------------------------------------------------------- 1 | [data-vaul-drawer] { 2 | touch-action: none; 3 | will-change: transform; 4 | transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); 5 | animation-duration: 0.5s; 6 | animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); 7 | } 8 | 9 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='bottom'][data-state='open'] { 10 | animation-name: slideFromBottom; 11 | } 12 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='bottom'][data-state='closed'] { 13 | animation-name: slideToBottom; 14 | } 15 | 16 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='top'][data-state='open'] { 17 | animation-name: slideFromTop; 18 | } 19 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='top'][data-state='closed'] { 20 | animation-name: slideToTop; 21 | } 22 | 23 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='left'][data-state='open'] { 24 | animation-name: slideFromLeft; 25 | } 26 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='left'][data-state='closed'] { 27 | animation-name: slideToLeft; 28 | } 29 | 30 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='right'][data-state='open'] { 31 | animation-name: slideFromRight; 32 | } 33 | [data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-direction='right'][data-state='closed'] { 34 | animation-name: slideToRight; 35 | } 36 | 37 | [data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-direction='bottom'] { 38 | transform: translate3d(0, var(--initial-transform, 100%), 0); 39 | } 40 | 41 | [data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-direction='top'] { 42 | transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0); 43 | } 44 | 45 | [data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-direction='left'] { 46 | transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); 47 | } 48 | 49 | [data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-direction='right'] { 50 | transform: translate3d(var(--initial-transform, 100%), 0, 0); 51 | } 52 | 53 | [data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-direction='top'] { 54 | transform: translate3d(0, var(--snap-point-height, 0), 0); 55 | } 56 | 57 | [data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-direction='bottom'] { 58 | transform: translate3d(0, var(--snap-point-height, 0), 0); 59 | } 60 | 61 | [data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-direction='left'] { 62 | transform: translate3d(var(--snap-point-height, 0), 0, 0); 63 | } 64 | 65 | [data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-direction='right'] { 66 | transform: translate3d(var(--snap-point-height, 0), 0, 0); 67 | } 68 | 69 | [data-vaul-overlay][data-vaul-snap-points='false'] { 70 | animation-duration: 0.5s; 71 | animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); 72 | } 73 | [data-vaul-overlay][data-vaul-snap-points='false'][data-state='open'] { 74 | animation-name: fadeIn; 75 | } 76 | [data-vaul-overlay][data-state='closed'] { 77 | animation-name: fadeOut; 78 | } 79 | 80 | [data-vaul-animate='false'] { 81 | animation: none !important; 82 | } 83 | 84 | [data-vaul-overlay][data-vaul-snap-points='true'] { 85 | opacity: 0; 86 | transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1); 87 | } 88 | 89 | [data-vaul-overlay][data-vaul-snap-points='true'] { 90 | opacity: 1; 91 | } 92 | 93 | [data-vaul-drawer]:not([data-vaul-custom-container='true'])::after { 94 | content: ''; 95 | position: absolute; 96 | background: inherit; 97 | background-color: inherit; 98 | } 99 | 100 | [data-vaul-drawer][data-vaul-drawer-direction='top']::after { 101 | top: initial; 102 | bottom: 100%; 103 | left: 0; 104 | right: 0; 105 | height: 200%; 106 | } 107 | 108 | [data-vaul-drawer][data-vaul-drawer-direction='bottom']::after { 109 | top: 100%; 110 | bottom: initial; 111 | left: 0; 112 | right: 0; 113 | height: 200%; 114 | } 115 | 116 | [data-vaul-drawer][data-vaul-drawer-direction='left']::after { 117 | left: initial; 118 | right: 100%; 119 | top: 0; 120 | bottom: 0; 121 | width: 200%; 122 | } 123 | 124 | [data-vaul-drawer][data-vaul-drawer-direction='right']::after { 125 | left: 100%; 126 | right: initial; 127 | top: 0; 128 | bottom: 0; 129 | width: 200%; 130 | } 131 | 132 | [data-vaul-overlay][data-vaul-snap-points='true']:not([data-vaul-snap-points-overlay='true']):not( 133 | [data-state='closed'] 134 | ) { 135 | opacity: 0; 136 | } 137 | 138 | [data-vaul-overlay][data-vaul-snap-points-overlay='true'] { 139 | opacity: 1; 140 | } 141 | 142 | [data-vaul-handle] { 143 | display: block; 144 | position: relative; 145 | opacity: 0.7; 146 | background: #e2e2e4; 147 | margin-left: auto; 148 | margin-right: auto; 149 | height: 5px; 150 | width: 32px; 151 | border-radius: 1rem; 152 | touch-action: pan-y; 153 | } 154 | 155 | [data-vaul-handle]:hover, 156 | [data-vaul-handle]:active { 157 | opacity: 1; 158 | } 159 | 160 | [data-vaul-handle-hitarea] { 161 | position: absolute; 162 | left: 50%; 163 | top: 50%; 164 | transform: translate(-50%, -50%); 165 | width: max(100%, 2.75rem); /* 44px */ 166 | height: max(100%, 2.75rem); /* 44px */ 167 | touch-action: inherit; 168 | } 169 | 170 | @media (hover: hover) and (pointer: fine) { 171 | [data-vaul-drawer] { 172 | user-select: none; 173 | } 174 | } 175 | 176 | @media (pointer: fine) { 177 | [data-vaul-handle-hitarea]: { 178 | width: 100%; 179 | height: 100%; 180 | } 181 | } 182 | 183 | @keyframes fadeIn { 184 | from { 185 | opacity: 0; 186 | } 187 | to { 188 | opacity: 1; 189 | } 190 | } 191 | 192 | @keyframes fadeOut { 193 | to { 194 | opacity: 0; 195 | } 196 | } 197 | 198 | @keyframes slideFromBottom { 199 | from { 200 | transform: translate3d(0, var(--initial-transform, 100%), 0); 201 | } 202 | to { 203 | transform: translate3d(0, 0, 0); 204 | } 205 | } 206 | 207 | @keyframes slideToBottom { 208 | to { 209 | transform: translate3d(0, var(--initial-transform, 100%), 0); 210 | } 211 | } 212 | 213 | @keyframes slideFromTop { 214 | from { 215 | transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0); 216 | } 217 | to { 218 | transform: translate3d(0, 0, 0); 219 | } 220 | } 221 | 222 | @keyframes slideToTop { 223 | to { 224 | transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0); 225 | } 226 | } 227 | 228 | @keyframes slideFromLeft { 229 | from { 230 | transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); 231 | } 232 | to { 233 | transform: translate3d(0, 0, 0); 234 | } 235 | } 236 | 237 | @keyframes slideToLeft { 238 | to { 239 | transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); 240 | } 241 | } 242 | 243 | @keyframes slideFromRight { 244 | from { 245 | transform: translate3d(var(--initial-transform, 100%), 0, 0); 246 | } 247 | to { 248 | transform: translate3d(0, 0, 0); 249 | } 250 | } 251 | 252 | @keyframes slideToRight { 253 | to { 254 | transform: translate3d(var(--initial-transform, 100%), 0, 0); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface SnapPoint { 2 | fraction: number 3 | height: number 4 | } 5 | 6 | export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right' 7 | 8 | export type AnyFunction = (...args: any) => any 9 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/usePositionFixed.ts: -------------------------------------------------------------------------------- 1 | import { type Ref, onMounted, onUnmounted, ref, watch } from 'vue' 2 | import { isSafari } from './browser' 3 | 4 | interface BodyPosition { 5 | position: string 6 | top: string 7 | left: string 8 | height: string 9 | } 10 | 11 | interface PositionFixedOptions { 12 | isOpen: Ref 13 | modal: Ref 14 | nested: Ref 15 | hasBeenOpened: Ref 16 | preventScrollRestoration: Ref 17 | noBodyStyles: Ref 18 | } 19 | 20 | let previousBodyPosition: BodyPosition | null = null 21 | 22 | export function usePositionFixed(options: PositionFixedOptions) { 23 | const { isOpen, modal, nested, hasBeenOpened, preventScrollRestoration, noBodyStyles } = options 24 | const activeUrl = ref(typeof window !== 'undefined' ? window.location.href : '') 25 | const scrollPos = ref(0) 26 | 27 | function setPositionFixed(): void { 28 | // All browsers on iOS will return true here. 29 | if (!isSafari()) 30 | return 31 | 32 | // If previousBodyPosition is already set, don't set it again. 33 | if (previousBodyPosition === null && isOpen.value && !noBodyStyles.value) { 34 | previousBodyPosition = { 35 | position: document.body.style.position, 36 | top: document.body.style.top, 37 | left: document.body.style.left, 38 | height: document.body.style.height, 39 | } 40 | 41 | // Update the dom inside an animation frame 42 | const { scrollX, innerHeight } = window 43 | 44 | document.body.style.setProperty('position', 'fixed', 'important') 45 | Object.assign(document.body.style, { 46 | top: `${-scrollPos.value}px`, 47 | left: `${-scrollX}px`, 48 | right: '0px', 49 | height: 'auto', 50 | }) 51 | 52 | setTimeout(() => { 53 | requestAnimationFrame(() => { 54 | // Attempt to check if the bottom bar appeared due to the position change 55 | const bottomBarHeight = innerHeight - window.innerHeight 56 | if (bottomBarHeight && scrollPos.value >= innerHeight) { 57 | // Move the content further up so that the bottom bar doesn't hide it 58 | document.body.style.top = `-${scrollPos.value + bottomBarHeight}px` 59 | } 60 | }) 61 | }, 300) 62 | } 63 | } 64 | 65 | function restorePositionSetting(): void { 66 | // All browsers on iOS will return true here. 67 | if (!isSafari()) 68 | return 69 | 70 | if (previousBodyPosition !== null && !noBodyStyles.value) { 71 | // Convert the position from "px" to Int 72 | const y = -Number.parseInt(document.body.style.top, 10) 73 | const x = -Number.parseInt(document.body.style.left, 10) 74 | 75 | // Restore styles 76 | Object.assign(document.body.style, previousBodyPosition) 77 | 78 | window.requestAnimationFrame(() => { 79 | if (preventScrollRestoration.value && activeUrl.value !== window.location.href) { 80 | activeUrl.value = window.location.href 81 | return 82 | } 83 | 84 | window.scrollTo(x, y) 85 | }) 86 | 87 | previousBodyPosition = null 88 | } 89 | } 90 | 91 | onMounted(() => { 92 | function onScroll() { 93 | scrollPos.value = window.scrollY 94 | } 95 | 96 | onScroll() 97 | window.addEventListener('scroll', onScroll) 98 | 99 | onUnmounted(() => { 100 | window.removeEventListener('scroll', onScroll) 101 | }) 102 | }) 103 | 104 | watch([isOpen, hasBeenOpened, activeUrl], () => { 105 | if (nested.value || !hasBeenOpened.value) 106 | return 107 | 108 | // This is needed to force Safari toolbar to show **before** the drawer starts animating to prevent a gnarly shift from happening 109 | if (isOpen.value) { 110 | // avoid for standalone mode (PWA) 111 | const isStandalone = window.matchMedia('(display-mode: standalone)').matches 112 | if (!isStandalone) 113 | setPositionFixed() 114 | 115 | if (!modal.value) { 116 | setTimeout(() => { 117 | restorePositionSetting() 118 | }, 500) 119 | } 120 | } 121 | else { 122 | restorePositionSetting() 123 | } 124 | }) 125 | 126 | return { restorePositionSetting } 127 | } 128 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/useScaleBackground.ts: -------------------------------------------------------------------------------- 1 | import { ref, watchEffect } from 'vue' 2 | import { injectDrawerRootContext } from './context' 3 | import { assignStyle, chain, isVertical, reset } from './helpers' 4 | import { BORDER_RADIUS, TRANSITIONS, WINDOW_TOP_OFFSET } from './constants' 5 | 6 | const noop = () => () => {} 7 | 8 | export function useScaleBackground() { 9 | const { direction, isOpen, shouldScaleBackground, setBackgroundColorOnScale, noBodyStyles } = injectDrawerRootContext() 10 | const timeoutIdRef = ref(null) 11 | const initialBackgroundColor = ref(document.body.style.backgroundColor) 12 | 13 | function getScale() { 14 | return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth 15 | } 16 | 17 | watchEffect((onCleanup) => { 18 | if (isOpen.value && shouldScaleBackground.value) { 19 | if (timeoutIdRef.value) 20 | clearTimeout(timeoutIdRef.value) 21 | const wrapper 22 | = (document.querySelector('[data-vaul-drawer-wrapper]') as HTMLElement) 23 | || (document.querySelector('[vaul-drawer-wrapper]') as HTMLElement) 24 | 25 | if (!wrapper) 26 | return 27 | 28 | chain( 29 | setBackgroundColorOnScale.value && !noBodyStyles.value ? assignStyle(document.body, { background: 'black' }) : noop, 30 | assignStyle(wrapper, { 31 | transformOrigin: isVertical(direction.value) ? 'top' : 'left', 32 | transitionProperty: 'transform, border-radius', 33 | transitionDuration: `${TRANSITIONS.DURATION}s`, 34 | transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 35 | }), 36 | ) 37 | 38 | const wrapperStylesCleanup = assignStyle(wrapper, { 39 | borderRadius: `${BORDER_RADIUS}px`, 40 | overflow: 'hidden', 41 | ...(isVertical(direction.value) 42 | ? { 43 | transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`, 44 | } 45 | : { 46 | transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`, 47 | }), 48 | }) 49 | 50 | onCleanup(() => { 51 | wrapperStylesCleanup() 52 | timeoutIdRef.value = window.setTimeout(() => { 53 | if (initialBackgroundColor.value) { 54 | document.body.style.background = initialBackgroundColor.value 55 | } 56 | else { 57 | document.body.style.removeProperty('background') 58 | } 59 | }, TRANSITIONS.DURATION * 1000) 60 | }) 61 | } 62 | }, { flush: 'pre' }) 63 | } 64 | -------------------------------------------------------------------------------- /packages/vaul-vue/src/useSnapPoints.ts: -------------------------------------------------------------------------------- 1 | import { type ComponentPublicInstance, type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' 2 | import { isVertical, set } from './helpers' 3 | import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants' 4 | import type { DrawerDirection } from './types' 5 | 6 | interface useSnapPointsProps { 7 | activeSnapPoint: Ref 8 | snapPoints: Ref<(number | string)[] | undefined> 9 | fadeFromIndex: Ref 10 | drawerRef: Ref 11 | overlayRef: Ref 12 | onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void 13 | direction: Ref 14 | } 15 | 16 | export function useSnapPoints({ 17 | activeSnapPoint, 18 | snapPoints, 19 | drawerRef, 20 | overlayRef, 21 | fadeFromIndex, 22 | onSnapPointChange, 23 | direction, 24 | }: useSnapPointsProps) { 25 | const windowDimensions = ref(typeof window !== 'undefined' 26 | ? { 27 | innerWidth: window.innerWidth, 28 | innerHeight: window.innerHeight, 29 | } 30 | : undefined) 31 | 32 | function onResize() { 33 | windowDimensions.value = { 34 | innerWidth: window.innerWidth, 35 | innerHeight: window.innerHeight, 36 | } 37 | } 38 | 39 | onMounted(() => { 40 | if (typeof window !== 'undefined') 41 | window.addEventListener('resize', onResize) 42 | }) 43 | 44 | onBeforeUnmount(() => { 45 | if (typeof window !== 'undefined') 46 | window.removeEventListener('resize', onResize) 47 | }) 48 | 49 | const isLastSnapPoint = computed( 50 | () => 51 | (snapPoints.value 52 | && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) 53 | ?? null, 54 | ) 55 | 56 | const shouldFade = computed( 57 | () => 58 | (snapPoints.value 59 | && snapPoints.value.length > 0 60 | && (fadeFromIndex?.value || fadeFromIndex?.value === 0) 61 | && !Number.isNaN(fadeFromIndex?.value) 62 | && snapPoints.value[fadeFromIndex?.value ?? -1] === activeSnapPoint.value) 63 | || !snapPoints.value, 64 | ) 65 | 66 | const activeSnapPointIndex = computed( 67 | () => snapPoints.value?.findIndex(snapPoint => snapPoint === activeSnapPoint.value) ?? null, 68 | ) 69 | 70 | const snapPointsOffset = computed( 71 | () => 72 | snapPoints.value?.map((snapPoint) => { 73 | const isPx = typeof snapPoint === 'string' 74 | let snapPointAsNumber = 0 75 | 76 | if (isPx) 77 | snapPointAsNumber = Number.parseInt(snapPoint, 10) 78 | 79 | if (isVertical(direction.value)) { 80 | const height = isPx ? snapPointAsNumber : windowDimensions.value ? snapPoint * windowDimensions.value.innerHeight : 0 81 | 82 | if (windowDimensions.value) 83 | return direction.value === 'bottom' ? windowDimensions.value.innerHeight - height : -windowDimensions.value.innerHeight + height 84 | 85 | return height 86 | } 87 | const width = isPx ? snapPointAsNumber : windowDimensions.value ? snapPoint * windowDimensions.value.innerWidth : 0 88 | 89 | if (windowDimensions.value) 90 | return direction.value === 'right' ? windowDimensions.value.innerWidth - width : -windowDimensions.value.innerWidth + width 91 | 92 | return width 93 | }) ?? [], 94 | ) 95 | 96 | const activeSnapPointOffset = computed(() => 97 | activeSnapPointIndex.value !== null 98 | ? snapPointsOffset.value?.[activeSnapPointIndex.value] 99 | : null, 100 | ) 101 | 102 | const snapToPoint = (dimension: number) => { 103 | const newSnapPointIndex = snapPointsOffset.value?.findIndex(snapPointDim => snapPointDim === dimension) ?? null 104 | 105 | // nextTick to allow el to be mounted before setting it. 106 | nextTick(() => { 107 | onSnapPointChange(newSnapPointIndex, snapPointsOffset.value) 108 | set(drawerRef.value?.$el, { 109 | transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 110 | transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`, 111 | }) 112 | }) 113 | 114 | if ( 115 | snapPointsOffset.value 116 | && newSnapPointIndex !== snapPointsOffset.value.length - 1 117 | && newSnapPointIndex !== fadeFromIndex?.value 118 | ) { 119 | set(overlayRef.value?.$el, { 120 | transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 121 | opacity: '0', 122 | }) 123 | } 124 | else { 125 | set(overlayRef.value?.$el, { 126 | transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 127 | opacity: '1', 128 | }) 129 | } 130 | 131 | activeSnapPoint.value 132 | = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null 133 | } 134 | 135 | watch( 136 | [activeSnapPoint, snapPointsOffset, snapPoints], 137 | () => { 138 | if (activeSnapPoint.value) { 139 | const newIndex 140 | = snapPoints.value?.findIndex(snapPoint => snapPoint === activeSnapPoint.value) ?? -1 141 | 142 | if ( 143 | snapPointsOffset.value 144 | && newIndex !== -1 145 | && typeof snapPointsOffset.value[newIndex] === 'number' 146 | ) 147 | snapToPoint(snapPointsOffset.value[newIndex]) 148 | } 149 | }, 150 | { 151 | immediate: true, // if you want to run the effect immediately as well 152 | }, 153 | ) 154 | 155 | function onRelease({ 156 | draggedDistance, 157 | closeDrawer, 158 | velocity, 159 | dismissible, 160 | }: { 161 | draggedDistance: number 162 | closeDrawer: () => void 163 | velocity: number 164 | dismissible: boolean 165 | }) { 166 | if (fadeFromIndex.value === undefined) 167 | return 168 | 169 | const currentPosition 170 | = direction.value === 'bottom' || direction.value === 'right' 171 | ? (activeSnapPointOffset.value ?? 0) - draggedDistance 172 | : (activeSnapPointOffset.value ?? 0) + draggedDistance 173 | const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1 174 | const isFirst = activeSnapPointIndex.value === 0 175 | const hasDraggedUp = draggedDistance > 0 176 | 177 | if (isOverlaySnapPoint) { 178 | set(overlayRef.value?.$el, { 179 | transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, 180 | }) 181 | } 182 | 183 | if (velocity > 2 && !hasDraggedUp) { 184 | if (dismissible) 185 | closeDrawer() 186 | else snapToPoint(snapPointsOffset.value[0]) // snap to initial point 187 | return 188 | } 189 | 190 | if (velocity > 2 && hasDraggedUp && snapPointsOffset && snapPoints.value) { 191 | snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1] as number) 192 | return 193 | } 194 | 195 | // Find the closest snap point to the current position 196 | const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => { 197 | if (typeof prev !== 'number' || typeof curr !== 'number') 198 | return prev 199 | 200 | return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev 201 | }) 202 | 203 | const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth 204 | if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) { 205 | const dragDirection = hasDraggedUp ? 1 : -1 // 1 = up, -1 = down 206 | 207 | // Don't do anything if we swipe upwards while being on the last snap point 208 | if (dragDirection > 0 && isLastSnapPoint) { 209 | snapToPoint(snapPointsOffset.value[(snapPoints.value?.length ?? 0) - 1]) 210 | return 211 | } 212 | 213 | if (isFirst && dragDirection < 0 && dismissible) 214 | closeDrawer() 215 | 216 | if (activeSnapPointIndex.value === null) 217 | return 218 | 219 | snapToPoint(snapPointsOffset.value[activeSnapPointIndex.value + dragDirection]) 220 | return 221 | } 222 | 223 | snapToPoint(closestSnapPoint) 224 | } 225 | 226 | function onDrag({ draggedDistance }: { draggedDistance: number }) { 227 | if (activeSnapPointOffset.value === null) 228 | return 229 | const newValue 230 | = direction.value === 'bottom' || direction.value === 'right' 231 | ? activeSnapPointOffset.value - draggedDistance 232 | : activeSnapPointOffset.value + draggedDistance 233 | 234 | // Don't do anything if we exceed the last(biggest) snap point 235 | if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1]) 236 | return 237 | 238 | if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1]) 239 | return 240 | 241 | set(drawerRef.value?.$el, { 242 | transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`, 243 | }) 244 | } 245 | 246 | function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) { 247 | if ( 248 | !snapPoints.value 249 | || typeof activeSnapPointIndex.value !== 'number' 250 | || !snapPointsOffset.value 251 | || fadeFromIndex.value === undefined 252 | ) 253 | return null 254 | 255 | // If this is true we are dragging to a snap point that is supposed to have an overlay 256 | const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1 257 | const isOverlaySnapPointOrHigher = activeSnapPointIndex.value >= fadeFromIndex.value 258 | 259 | if (isOverlaySnapPointOrHigher && isDraggingDown) 260 | return 0 261 | 262 | // Don't animate, but still use this one if we are dragging away from the overlaySnapPoint 263 | if (isOverlaySnapPoint && !isDraggingDown) 264 | return 1 265 | if (!shouldFade.value && !isOverlaySnapPoint) 266 | return null 267 | 268 | // Either fadeFrom index or the one before 269 | const targetSnapPointIndex = isOverlaySnapPoint 270 | ? activeSnapPointIndex.value + 1 271 | : activeSnapPointIndex.value - 1 272 | 273 | // Get the distance from overlaySnapPoint to the one before or vice-versa to calculate the opacity percentage accordingly 274 | const snapPointDistance = isOverlaySnapPoint 275 | ? snapPointsOffset.value[targetSnapPointIndex] 276 | - snapPointsOffset.value[targetSnapPointIndex - 1] 277 | : snapPointsOffset.value[targetSnapPointIndex + 1] 278 | - snapPointsOffset.value[targetSnapPointIndex] 279 | 280 | const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance) 281 | 282 | if (isOverlaySnapPoint) 283 | return 1 - percentageDragged 284 | else 285 | return percentageDragged 286 | } 287 | 288 | return { 289 | isLastSnapPoint, 290 | shouldFade, 291 | getPercentageDragged, 292 | activeSnapPointIndex, 293 | onRelease, 294 | onDrag, 295 | snapPointsOffset, 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /packages/vaul-vue/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "baseUrl": "../..", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | }, 9 | "noEmit": true 10 | }, 11 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 12 | "exclude": ["src/**/__tests__/*"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/vaul-vue/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.app.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "jsx": "preserve", 6 | "lib": ["esnext", "dom"], 7 | "baseUrl": ".", 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "paths": { 11 | "@/*": ["src/*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "strict": true, 15 | "declaration": false, 16 | "outDir": "dist", 17 | "sourceMap": true, 18 | "esModuleInterop": true, 19 | "skipLibCheck": true 20 | }, 21 | "include": ["env.d.ts", "src/**/*", "src/**/*.ts", "src/**/*.vue"], 22 | "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/vaul-vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { 4 | "path": "./tsconfig.node.json" 5 | }, 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.vitest.json" 11 | } 12 | ], 13 | "files": [] 14 | } 15 | -------------------------------------------------------------------------------- /packages/vaul-vue/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "types": ["node"], 8 | "noEmit": true 9 | }, 10 | "include": [ 11 | "vite.config.*", 12 | "vitest.config.*", 13 | "cypress.config.*", 14 | "nightwatch.conf.*", 15 | "playwright.config.*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/vaul-vue/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "lib": [], 6 | "types": ["node", "jsdom"] 7 | }, 8 | "exclude": [] 9 | } 10 | -------------------------------------------------------------------------------- /packages/vaul-vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import dts from 'vite-plugin-dts' 5 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | vue(), 11 | cssInjectedByJsPlugin({ useStrictCSP: true }), 12 | dts({ 13 | tsconfigPath: 'tsconfig.build.json', 14 | cleanVueFileName: true, 15 | rollupTypes: true, 16 | }), 17 | ], 18 | resolve: { 19 | alias: { 20 | '@': resolve(__dirname, 'src'), 21 | }, 22 | }, 23 | build: { 24 | lib: { 25 | name: 'vaul-vue', 26 | fileName: 'index', 27 | entry: resolve(__dirname, 'src/index.ts'), 28 | }, 29 | outDir: 'dist', 30 | rollupOptions: { 31 | // make sure to externalize deps that shouldn't be bundled 32 | // into your library (Vue) 33 | external: ['vue', 'reka-ui'], 34 | output: { 35 | // Provide global variables to use in the UMD build 36 | // for externalized deps 37 | globals: { 38 | vue: 'Vue', 39 | }, 40 | assetFileNames: (chunkInfo) => { 41 | if (chunkInfo.name === 'style.css') 42 | return 'index.css' 43 | return chunkInfo.name as string 44 | }, 45 | }, 46 | }, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /packages/vaul-vue/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { configDefaults, defineConfig, mergeConfig } from 'vitest/config' 3 | import viteConfig from './vite.config' 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | environment: 'jsdom', 10 | exclude: [...configDefaults.exclude, 'e2e/*'], 11 | root: fileURLToPath(new URL('./', import.meta.url)), 12 | }, 13 | }), 14 | ) 15 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | -------------------------------------------------------------------------------- /playground/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /playground/README.md: -------------------------------------------------------------------------------- 1 | # playground 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | pnpm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | pnpm dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | pnpm build 40 | ``` 41 | -------------------------------------------------------------------------------- /playground/e2e/base.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { ANIMATION_DURATION } from './constants' 3 | import { openDrawer } from './helpers' 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/test/without-scaled-background') 7 | }) 8 | 9 | test.describe('Base tests', () => { 10 | test('should open drawer', async ({ page }) => { 11 | await expect(page.getByTestId('content')).not.toBeVisible() 12 | 13 | await page.getByTestId('trigger').click() 14 | 15 | await expect(page.getByTestId('content')).toBeVisible() 16 | }) 17 | 18 | test('should close on background interaction', async ({ page }) => { 19 | await openDrawer(page) 20 | // Click on the background 21 | await page.mouse.click(0, 0) 22 | 23 | await page.waitForTimeout(ANIMATION_DURATION) 24 | await expect(page.getByTestId('content')).not.toBeVisible() 25 | }) 26 | 27 | test('should close when `Drawer.Close` is clicked', async ({ page }) => { 28 | await openDrawer(page) 29 | 30 | await page.getByTestId('drawer-close').click() 31 | await page.waitForTimeout(ANIMATION_DURATION) 32 | await expect(page.getByTestId('content')).not.toBeVisible() 33 | }) 34 | 35 | test('should close when controlled', async ({ page }) => { 36 | await openDrawer(page) 37 | 38 | await page.getByTestId('controlled-close').click() 39 | await page.waitForTimeout(ANIMATION_DURATION) 40 | await expect(page.getByTestId('content')).not.toBeVisible() 41 | }) 42 | 43 | test('should close when dragged down', async ({ page }) => { 44 | await openDrawer(page) 45 | await page.hover('[data-vaul-drawer]') 46 | await page.mouse.down() 47 | await page.mouse.move(0, 500) 48 | await page.mouse.up() 49 | await page.waitForTimeout(ANIMATION_DURATION) 50 | await expect(page.getByTestId('content')).not.toBeVisible() 51 | }) 52 | 53 | test('should not close when dragged up', async ({ page }) => { 54 | await openDrawer(page) 55 | await page.hover('[data-vaul-drawer]') 56 | await page.mouse.down() 57 | await page.mouse.move(0, -500) 58 | await page.mouse.up() 59 | await page.waitForTimeout(ANIMATION_DURATION) 60 | await expect(page.getByTestId('content')).toBeVisible() 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /playground/e2e/constants.ts: -------------------------------------------------------------------------------- 1 | export const ANIMATION_DURATION = 500 2 | -------------------------------------------------------------------------------- /playground/e2e/controlled.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { ANIMATION_DURATION } from './constants' 3 | 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/test/controlled') 6 | }) 7 | 8 | test.describe('Controlled', () => { 9 | test('should not close when clicked on overlay and only the open prop is passsed', async ({ 10 | page, 11 | }) => { 12 | await expect(page.getByTestId('content')).not.toBeVisible() 13 | await page.getByTestId('trigger').click() 14 | await expect(page.getByTestId('content')).toBeVisible() 15 | // Click on the background 16 | await page.mouse.click(0, 0) 17 | 18 | await page.waitForTimeout(ANIMATION_DURATION) 19 | await expect(page.getByTestId('content')).toBeVisible() 20 | }) 21 | 22 | test('should close when clicked on overlay and open and onOpenChange props are passed', async ({ 23 | page, 24 | }) => { 25 | await expect(page.getByTestId('fully-controlled-content')).not.toBeVisible() 26 | await page.getByTestId('fully-controlled-trigger').click() 27 | await expect(page.getByTestId('fully-controlled-content')).toBeVisible() 28 | // Click on the background 29 | await page.mouse.click(0, 0) 30 | 31 | await page.waitForTimeout(ANIMATION_DURATION) 32 | await expect(page.getByTestId('fully-controlled-content')).not.toBeVisible() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /playground/e2e/direction.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { ANIMATION_DURATION } from './constants' 3 | import { openDrawer } from './helpers' 4 | 5 | test.describe('Direction tests', () => { 6 | test.describe('direction - bottom (default)', () => { 7 | test.beforeEach(async ({ page }) => { 8 | await page.goto('/test/direction') 9 | }) 10 | 11 | test('should close when dragged down', async ({ page }) => { 12 | await openDrawer(page) 13 | await page.hover('[data-vaul-drawer]') 14 | await page.mouse.down() 15 | await page.mouse.move(0, 600) 16 | await page.mouse.up() 17 | await page.waitForTimeout(ANIMATION_DURATION) 18 | await expect(page.getByTestId('content')).not.toBeVisible() 19 | }) 20 | 21 | test('should not close when dragged up', async ({ page }) => { 22 | await openDrawer(page) 23 | await page.hover('[data-vaul-drawer]') 24 | await page.mouse.down() 25 | await page.mouse.move(0, -600) 26 | await page.mouse.up() 27 | await page.waitForTimeout(ANIMATION_DURATION) 28 | await expect(page.getByTestId('content')).toBeVisible() 29 | }) 30 | }) 31 | 32 | test.describe('direction - left', () => { 33 | test.beforeEach(async ({ page }) => { 34 | await page.goto('/test/direction?direction=left') 35 | }) 36 | 37 | test('should close when dragged left', async ({ page }) => { 38 | await openDrawer(page) 39 | await page.hover('[data-vaul-drawer]') 40 | await page.mouse.down() 41 | await page.mouse.move(200, 0) 42 | await page.mouse.up() 43 | await page.waitForTimeout(ANIMATION_DURATION) 44 | await expect(page.getByTestId('content')).not.toBeVisible() 45 | }) 46 | 47 | test('should not close when dragged right', async ({ page }) => { 48 | await openDrawer(page) 49 | await page.hover('[data-vaul-drawer]') 50 | await page.mouse.down() 51 | await page.mouse.move(800, 0) 52 | await page.mouse.up() 53 | await page.waitForTimeout(ANIMATION_DURATION) 54 | await expect(page.getByTestId('content')).toBeVisible() 55 | }) 56 | }) 57 | 58 | test.describe('direction - right', () => { 59 | test.beforeEach(async ({ page }) => { 60 | await page.goto('/test/direction?direction=right') 61 | }) 62 | 63 | test('should close when dragged right', async ({ page }) => { 64 | await openDrawer(page) 65 | await page.hover('[data-vaul-drawer]') 66 | await page.mouse.down() 67 | await page.mouse.move(1200, 0) 68 | await page.mouse.up() 69 | await page.waitForTimeout(ANIMATION_DURATION) 70 | await expect(page.getByTestId('content')).not.toBeVisible() 71 | }) 72 | 73 | test('should not close when dragged left', async ({ page }) => { 74 | await openDrawer(page) 75 | await page.hover('[data-vaul-drawer]') 76 | await page.mouse.down() 77 | await page.mouse.move(-1200, 0) 78 | await page.mouse.up() 79 | await page.waitForTimeout(ANIMATION_DURATION) 80 | await expect(page.getByTestId('content')).toBeVisible() 81 | }) 82 | }) 83 | 84 | test.describe('direction - top', () => { 85 | test.beforeEach(async ({ page }) => { 86 | await page.goto('/test/direction?direction=top') 87 | }) 88 | 89 | test('should close when dragged top', async ({ page }) => { 90 | await openDrawer(page) 91 | await page.hover('[data-vaul-drawer]') 92 | await page.mouse.down() 93 | await page.mouse.move(0, 100) 94 | await page.mouse.up() 95 | await page.waitForTimeout(ANIMATION_DURATION) 96 | await expect(page.getByTestId('content')).not.toBeVisible() 97 | }) 98 | 99 | test('should not close when dragged down', async ({ page }) => { 100 | await openDrawer(page) 101 | await page.hover('[data-vaul-drawer]') 102 | await page.mouse.down() 103 | await page.mouse.move(0, 600) 104 | await page.mouse.up() 105 | await page.waitForTimeout(ANIMATION_DURATION) 106 | await expect(page.getByTestId('content')).toBeVisible() 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /playground/e2e/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from '@playwright/test' 2 | import { expect } from '@playwright/test' 3 | import { ANIMATION_DURATION } from './constants' 4 | 5 | export async function openDrawer(page: Page) { 6 | await expect(page.getByTestId('content')).not.toBeVisible() 7 | await page.getByTestId('trigger').click() 8 | await page.waitForTimeout(ANIMATION_DURATION) 9 | await expect(page.getByTestId('content')).toBeVisible() 10 | } 11 | 12 | // export async function dragWithSpeed( 13 | // page: Page, 14 | // selector: string, 15 | // startY: number, 16 | // endY: number, 17 | // speed: number = 10, 18 | // ): Promise { 19 | // const startX = 0; 20 | // const distance = Math.abs(endY - startY); 21 | // const steps = distance / speed; 22 | // const delayPerStep = 10; // in milliseconds 23 | // const yOffset = (endY - startY) / steps; 24 | // 25 | // await page.hover(selector); 26 | // await page.mouse.down(); 27 | // await page.mouse.move(0, -200); 28 | // await page.mouse.up(); 29 | // } 30 | -------------------------------------------------------------------------------- /playground/e2e/initial-snap.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { ANIMATION_DURATION } from './constants' 3 | 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/test/initial-snap') 6 | }) 7 | 8 | // const snapPointYPositions = { 9 | // 0: 800, 10 | // 1: 600, 11 | // 2: 590, 12 | // 3: 200 13 | // } as const 14 | 15 | // const snapTo = async (page: Page, snapPointIndex: keyof typeof snapPointYPositions) => { 16 | // await page.hover('[data-vaul-drawer]') 17 | // await page.mouse.down() 18 | // await page.mouse.move(0, snapPointYPositions[snapPointIndex]) 19 | // await page.mouse.up() 20 | // await page.waitForTimeout(ANIMATION_DURATION) 21 | // } 22 | 23 | test.describe('Initial-snap', () => { 24 | test('should be open and snapped on initial load', async ({ page }) => { 25 | await page.waitForTimeout(ANIMATION_DURATION) 26 | 27 | await expect(page.getByTestId('content')).toBeVisible() 28 | await expect(page.getByTestId('active-snap-index')).toHaveText('1') 29 | }) 30 | 31 | // test('should snap to next snap point when dragged up', async ({ page }) => { 32 | // snapTo(page, 2); 33 | 34 | // await expect(page.getByTestId('active-snap-index')).toHaveText('2'); 35 | // }); 36 | 37 | // test('should snap to last snap point when dragged up', async ({ page }) => { 38 | // snapTo(page, 3); 39 | 40 | // await expect(page.getByTestId('active-snap-index')).toHaveText('3'); 41 | // }); 42 | 43 | // test('should snap to 0 when dragged down', async ({ page }) => { 44 | // snapTo(page, 0); 45 | 46 | // await expect(page.getByTestId('active-snap-index')).toHaveText('0'); 47 | // }); 48 | }) 49 | -------------------------------------------------------------------------------- /playground/e2e/nested.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { ANIMATION_DURATION } from './constants' 3 | import { openDrawer } from './helpers' 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/test/nested-drawer') 7 | }) 8 | 9 | test.describe('Nested tests', () => { 10 | test('should open and close nested drawer', async ({ page }) => { 11 | await openDrawer(page) 12 | await page.getByTestId('nested-trigger').click() 13 | await page.waitForTimeout(ANIMATION_DURATION) 14 | await expect(page.getByTestId('nested-content')).toBeVisible() 15 | await page.getByTestId('nested-close').click() 16 | await page.waitForTimeout(ANIMATION_DURATION) 17 | await expect(page.getByTestId('nested-content')).not.toBeVisible() 18 | await expect(page.getByTestId('content')).toBeVisible() 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /playground/e2e/no-drag-element.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { openDrawer } from './helpers' 3 | import { ANIMATION_DURATION } from './constants' 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/test/no-drag-element') 7 | }) 8 | 9 | test.describe('No drag element', () => { 10 | test('should close when dragged down', async ({ page }) => { 11 | await openDrawer(page) 12 | await page.hover('[data-testid=handler]') 13 | await page.mouse.down() 14 | await page.mouse.move(0, 500) 15 | await page.mouse.up() 16 | await page.waitForTimeout(ANIMATION_DURATION) 17 | await expect(page.getByTestId('content')).not.toBeVisible() 18 | }) 19 | 20 | test('should not close when dragged down', async ({ page }) => { 21 | await openDrawer(page) 22 | await page.hover('[data-vaul-no-drag]') 23 | await page.mouse.down() 24 | await page.mouse.move(0, 500) 25 | await page.mouse.up() 26 | await page.waitForTimeout(ANIMATION_DURATION) 27 | await expect(page.getByTestId('content')).toBeVisible() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /playground/e2e/non-dismissible.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { openDrawer } from './helpers' 3 | import { ANIMATION_DURATION } from './constants' 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/test/non-dismissible') 7 | }) 8 | 9 | test.describe('Non-dismissible', () => { 10 | test('should not close on background interaction', async ({ page }) => { 11 | await openDrawer(page) 12 | // Click on the background 13 | await page.mouse.click(0, 0) 14 | await page.waitForTimeout(ANIMATION_DURATION) 15 | await expect(page.getByTestId('content')).toBeVisible() 16 | }) 17 | 18 | test('should not close when dragged down', async ({ page }) => { 19 | await openDrawer(page) 20 | await page.hover('[data-vaul-drawer]') 21 | await page.mouse.down() 22 | await page.mouse.move(0, 800) 23 | await page.mouse.up() 24 | await page.waitForTimeout(ANIMATION_DURATION) 25 | await expect(page.getByTestId('content')).toBeVisible() 26 | }) 27 | 28 | test('should close when the dismiss button is clicked', async ({ page }) => { 29 | await openDrawer(page) 30 | 31 | await page.getByTestId('dismiss-button').click() 32 | await page.waitForTimeout(ANIMATION_DURATION) 33 | await expect(page.getByTestId('content')).not.toBeVisible() 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /playground/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "bundler" 5 | }, 6 | "include": ["./**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /playground/e2e/with-handle.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { ANIMATION_DURATION } from './constants' 3 | 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/test/with-handle') 6 | }) 7 | 8 | test.describe('With handle', () => { 9 | test('click should cycle to the next snap point', async ({ page }) => { 10 | await page.waitForTimeout(ANIMATION_DURATION) 11 | 12 | await expect(page.getByTestId('content')).toBeVisible() 13 | await expect(page.getByTestId('active-snap-index')).toHaveText('0') 14 | 15 | await page.getByTestId('handle').click() 16 | await expect(page.getByTestId('active-snap-index')).toHaveText('1') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /playground/e2e/with-scaled-background.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { openDrawer } from './helpers' 3 | 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('/test/with-scaled-background') 6 | }) 7 | 8 | test.describe('With scaled background', () => { 9 | test('should scale background', async ({ page }) => { 10 | await expect(page.locator('[data-vaul-drawer-wrapper]')).not.toHaveCSS('transform', '') 11 | 12 | await page.getByTestId('trigger').click() 13 | 14 | await expect(page.locator('[data-vaul-drawer-wrapper]')).toHaveCSS('transform', /matrix/) 15 | }) 16 | 17 | test('should scale background when dragging', async ({ page }) => { 18 | await expect(page.locator('[data-vaul-drawer-wrapper]')).not.toHaveCSS('transform', '') 19 | 20 | await openDrawer(page) 21 | 22 | await page.hover('[data-vaul-drawer]') 23 | await page.mouse.down() 24 | await page.mouse.move(0, 100) 25 | 26 | await expect(page.locator('[data-vaul-drawer-wrapper]')).toHaveCSS('transform', /matrix/) 27 | 28 | await page.mouse.up() 29 | 30 | await expect(page.locator('[data-vaul-drawer-wrapper]')).not.toHaveCSS('transform', '') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /playground/e2e/without-scaled-background.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/test/without-scaled-background') 5 | }) 6 | 7 | test.describe('Without scaled background', () => { 8 | test('should not scale background', async ({ page }) => { 9 | await expect(page.locator('[data-vaul-drawer-wrapper]')).not.toHaveCSS('transform', '') 10 | 11 | await page.getByTestId('trigger').click() 12 | 13 | await expect(page.locator('[data-vaul-drawer-wrapper]')).not.toHaveCSS('transform', '') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /playground/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite App 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "run-p type-check \"build-only {@}\" --", 9 | "preview": "vite preview", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --build --force", 12 | "test:e2e": "playwright test", 13 | "test": "pnpm run test:e2e" 14 | }, 15 | "dependencies": { 16 | "reka-ui": "^2.0.0", 17 | "vaul-vue": "workspace:*", 18 | "vue": "^3.4.5", 19 | "vue-router": "4" 20 | }, 21 | "devDependencies": { 22 | "@tsconfig/node18": "^18.2.2", 23 | "@types/node": "^18.19.3", 24 | "@vitejs/plugin-vue": "^4.5.2", 25 | "@vue/tsconfig": "^0.5.0", 26 | "autoprefixer": "^10.4.16", 27 | "npm-run-all2": "^6.1.1", 28 | "postcss": "^8.4.32", 29 | "tailwindcss": "^3.4.0", 30 | "typescript": "~5.3.0", 31 | "vite": "^5.0.10", 32 | "vue-tsc": "^1.8.25" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /playground/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './e2e', 14 | /* Maximum time one test can run for. */ 15 | timeout: 30 * 1000, 16 | expect: { 17 | /** 18 | * Maximum time expect() should wait for the condition to be met. 19 | * For example in `await expect(locator).toHaveText();` 20 | */ 21 | timeout: 5000, 22 | }, 23 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 24 | forbidOnly: !!process.env.CI, 25 | /* Retry on CI only */ 26 | retries: process.env.CI ? 2 : 0, 27 | /* Opt out of parallel tests on CI. */ 28 | workers: process.env.CI ? 4 : undefined, 29 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 30 | reporter: 'html', 31 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 32 | use: { 33 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 34 | actionTimeout: 0, 35 | /* Base URL to use in actions like `await page.goto('/')`. */ 36 | baseURL: 'http://localhost:5173', 37 | 38 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 39 | trace: 'on-first-retry', 40 | 41 | /* Only on CI systems run the tests headless */ 42 | headless: !!process.env.CI, 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'], 51 | }, 52 | }, 53 | { 54 | name: 'firefox', 55 | use: { 56 | ...devices['Desktop Firefox'], 57 | }, 58 | }, 59 | 60 | /** Ignore testing safari for now */ 61 | // { 62 | // name: 'webkit', 63 | // use: { 64 | // ...devices['Desktop Safari'], 65 | // viewport: { width: 1600, height: 1200 }, 66 | // }, 67 | // }, 68 | 69 | /* Test against mobile viewports. */ 70 | // { 71 | // name: 'Mobile Chrome', 72 | // use: { 73 | // ...devices['Pixel 5'], 74 | // }, 75 | // }, 76 | // { 77 | // name: 'Mobile Safari', 78 | // use: { 79 | // ...devices['iPhone 12'], 80 | // }, 81 | // }, 82 | 83 | /* Test against branded browsers. */ 84 | // { 85 | // name: 'Microsoft Edge', 86 | // use: { 87 | // channel: 'msedge', 88 | // }, 89 | // }, 90 | // { 91 | // name: 'Google Chrome', 92 | // use: { 93 | // channel: 'chrome', 94 | // }, 95 | // }, 96 | ], 97 | 98 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 99 | // outputDir: 'test-results/', 100 | 101 | /* Run your local dev server before starting the tests */ 102 | webServer: { 103 | /** 104 | * Use the dev server by default for faster feedback loop. 105 | * Use the preview server on CI for more realistic testing. 106 | * Playwright will re-use the local server if there is already a dev-server running. 107 | */ 108 | command: process.env.CI ? 'vite build && vite preview --port 5173' : 'vite dev', 109 | port: 5173, 110 | reuseExistingServer: !process.env.CI, 111 | }, 112 | }) 113 | -------------------------------------------------------------------------------- /playground/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unovue/vaul-vue/1b1f6dfdba6a775410508884097443d35c9a8690/playground/public/favicon.ico -------------------------------------------------------------------------------- /playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /playground/src/assets/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --vaul-overlay-background: 255, 255, 255; 7 | --vaul-overlay-background-start: rgba(0, 0, 0, 0); 8 | --vaul-overlay-background-end: rgba(0, 0, 0, 0.4); 9 | } 10 | 11 | html, 12 | body { 13 | overflow: hidden; 14 | } 15 | 16 | body, 17 | main { 18 | min-height: 100vh; 19 | /* mobile viewport bug fix */ 20 | min-height: -webkit-fill-available; 21 | overflow-x: hidden; 22 | } 23 | 24 | html { 25 | background: black; 26 | } 27 | 28 | main { 29 | min-height: 100vh; 30 | background: white; 31 | } 32 | 33 | html { 34 | height: -webkit-fill-available; 35 | } 36 | 37 | a { 38 | text-decoration-thickness: 1px; 39 | text-underline-offset: 2px; 40 | } 41 | -------------------------------------------------------------------------------- /playground/src/components/BackgroundTexture.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /playground/src/components/DemoDrawer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 51 | -------------------------------------------------------------------------------- /playground/src/components/DrawerContent.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 45 | -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/style.css' 2 | 3 | import { createApp } from 'vue' 4 | import App from './App.vue' 5 | import router from '@/router' 6 | 7 | const app = createApp(App) 8 | 9 | app.use(router) 10 | 11 | app.mount('#app') 12 | -------------------------------------------------------------------------------- /playground/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | 3 | const router = createRouter({ 4 | history: createWebHistory(), 5 | routes: [ 6 | { 7 | path: '/', 8 | component: () => import('../views/HomeView.vue'), 9 | }, 10 | { 11 | path: '/test', 12 | children: [ 13 | { 14 | path: 'controlled', 15 | component: () => import('../views/tests/ControlledView.vue'), 16 | }, 17 | { 18 | path: 'no-drag-element', 19 | component: () => import('../views/tests/NoDragElementView.vue'), 20 | }, 21 | { 22 | path: 'initial-snap', 23 | component: () => import('../views/tests/InitialSnapView.vue'), 24 | }, 25 | { 26 | path: 'direction', 27 | component: () => import('../views/tests/DirectionView.vue'), 28 | }, 29 | { 30 | path: 'nested-drawer', 31 | component: () => import('../views/tests/NestedDrawerView.vue'), 32 | }, 33 | { 34 | path: 'non-dismissible', 35 | component: () => import('../views/tests/NonDismissibleView.vue'), 36 | }, 37 | { 38 | path: 'scrollable-with-inputs', 39 | component: () => import('../views/tests/ScrollableWithInputsView.vue'), 40 | }, 41 | { 42 | path: 'without-scaled-background', 43 | component: () => import('../views/tests/WithoutScaledBackgroundView.vue'), 44 | }, 45 | { 46 | path: 'with-handle', 47 | component: () => import('../views/tests/WithHandleView.vue'), 48 | }, 49 | { 50 | path: 'with-scaled-background', 51 | component: () => import('../views/tests/WithScaledBackgroundView.vue'), 52 | }, 53 | { 54 | path: 'with-snap-points', 55 | component: () => import('../views/tests/WithSnapPointsView.vue'), 56 | }, 57 | 58 | ], 59 | }, 60 | ], 61 | }) 62 | 63 | export default router 64 | -------------------------------------------------------------------------------- /playground/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /playground/src/views/tests/ControlledView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /playground/src/views/tests/DirectionView.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /playground/src/views/tests/InitialSnapView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /playground/src/views/tests/NestedDrawerView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /playground/src/views/tests/NoDragElementView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 58 | -------------------------------------------------------------------------------- /playground/src/views/tests/NonDismissibleView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /playground/src/views/tests/ScrollableWithInputsView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /playground/src/views/tests/WithHandleView.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 177 | -------------------------------------------------------------------------------- /playground/src/views/tests/WithScaledBackgroundView.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /playground/src/views/tests/WithSnapPointsView.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /playground/src/views/tests/WithoutScaledBackgroundView.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /playground/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /playground/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | }, 9 | "noEmit": true 10 | }, 11 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 12 | "exclude": ["src/**/__tests__/*"] 13 | } 14 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { 4 | "path": "./tsconfig.node.json" 5 | }, 6 | { 7 | "path": "./tsconfig.app.json" 8 | } 9 | ], 10 | "files": [] 11 | } 12 | -------------------------------------------------------------------------------- /playground/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "types": ["node"], 8 | "noEmit": true 9 | }, 10 | "include": [ 11 | "vite.config.*", 12 | "vitest.config.*", 13 | "cypress.config.*", 14 | "nightwatch.conf.*", 15 | "playwright.config.*"] 16 | } 17 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { URL, fileURLToPath } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [vue()], 9 | resolve: { 10 | alias: { 11 | '@': fileURLToPath(new URL('./src', import.meta.url)), 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - playground 4 | --------------------------------------------------------------------------------