├── aioli ├── env.d.ts ├── src │ ├── index.ts │ ├── Overlay.vue │ ├── context.ts │ ├── styles.css │ ├── nuxt.ts │ ├── Content.vue │ ├── helpers.ts │ └── Root.vue ├── tsconfig.json ├── vite.config.ts └── package.json ├── .npmrc ├── pnpm-workspace.yaml ├── website ├── server │ └── tsconfig.json ├── public │ └── favicon.ico ├── tsconfig.json ├── .gitignore ├── nuxt.config.ts ├── package.json └── app │ ├── components │ ├── GithubIcon.vue │ └── example │ │ └── ExampleSystemTray.vue │ ├── app.vue │ └── auto-animate.ts ├── .prettierrc.json ├── .github └── workflows │ ├── pr-ci.yaml │ ├── main-ci.yaml │ ├── release.yaml │ └── checks.yaml ├── .eslintrc.cjs ├── .gitignore ├── package.json ├── LICENSE.md └── README.md /aioli/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'aioli' 3 | - 'website' 4 | -------------------------------------------------------------------------------- /website/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } -------------------------------------------------------------------------------- /website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/94726/aioli/HEAD/website/public/favicon.ico -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "endOfLine": "auto" 6 | } 7 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/pr-ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | checks: 9 | uses: ./.github/workflows/checks.yaml 10 | -------------------------------------------------------------------------------- /aioli/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DialogRoot } from './Root.vue' 2 | export { default as DialogOverlay } from './Overlay.vue' 3 | export { default as DialogContent } from './Content.vue' 4 | export { DialogTrigger, DialogPortal, DialogClose, DialogTitle, DialogDescription } from 'radix-vue' 5 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .nuxt 4 | .nitro 5 | .cache 6 | dist 7 | 8 | # Node dependencies 9 | node_modules 10 | 11 | # Logs 12 | logs 13 | *.log 14 | 15 | # Misc 16 | .DS_Store 17 | .fleet 18 | .idea 19 | 20 | # Local env files 21 | .env 22 | .env.* 23 | !.env.example 24 | -------------------------------------------------------------------------------- /website/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | compatibilityDate: '2024-08-03', 4 | devtools: { enabled: true }, 5 | modules: ['radix-vue/nuxt', '@nuxtjs/tailwindcss', '../aioli/src/nuxt', '@formkit/auto-animate/nuxt', '@nuxt/icon'], 6 | future: { 7 | compatibilityVersion: 4, 8 | }, 9 | app: { 10 | baseURL: '/aioli/', 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'plugin:vue/vue3-recommended', 5 | 'eslint:recommended', 6 | 'plugin:prettier/recommended', 7 | '@vue/eslint-config-typescript/recommended', 8 | ], 9 | ignorePatterns: ['dist', 'node_modules'], 10 | plugins: ['prettier'], 11 | rules: { 12 | 'prettier/prettier': ['warn'], 13 | 'no-console': [ 14 | 'warn', 15 | { 16 | allow: ['warn', 'error'], 17 | }, 18 | ], 19 | 'no-undef': 'off', 20 | 'vue/multi-word-component-names': 0, 21 | 'vue/no-dupe-keys': 0, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /aioli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "env.d.ts", 4 | "src/**/*", 5 | "src/**/*.ts", 6 | "src/**/*.tsx", 7 | "src/**/*.vue" 8 | ], 9 | "compilerOptions": { 10 | "target": "esnext", 11 | "verbatimModuleSyntax": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "types": ["node"], 15 | "strict": true, 16 | "jsx": "preserve", 17 | "sourceMap": true, 18 | "resolveJsonModule": true, 19 | "esModuleInterop": true, 20 | "declaration": false, 21 | "lib": ["esnext", "dom"], 22 | "baseUrl": ".", 23 | "skipLibCheck": true, 24 | "outDir": "dist" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aioli-playground-nuxt", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "typecheck": "nuxt typecheck", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "dependencies": { 13 | "@formkit/auto-animate": "^0.8.2", 14 | "aioli": "workspace:*" 15 | }, 16 | "devDependencies": { 17 | "@iconify-json/uil": "^1.1.9", 18 | "@iconify-json/pepicons": "^1.1.13", 19 | "@nuxt/devtools": "latest", 20 | "@nuxt/icon": "^1.4.5", 21 | "@nuxtjs/tailwindcss": "^6.12.1", 22 | "nuxt": "^3.12.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /aioli/src/Overlay.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk -------------------------------------------------------------------------------- /aioli/src/context.ts: -------------------------------------------------------------------------------- 1 | import { provide, inject, type InjectionKey, type Ref } from 'vue' 2 | 3 | interface DrawerContextValue { 4 | drawerRef: Ref 5 | overlayRef: Ref 6 | onPress: (event: PointerEvent) => void 7 | onRelease: (event: PointerEvent) => void 8 | onDrag: (event: PointerEvent) => void 9 | persistent: Ref> 10 | openProp: Ref 11 | keyboardIsOpen: Ref 12 | modal: Ref> 13 | visible: Ref 14 | allowMouseDrag: Ref 15 | } 16 | 17 | const DrawerContext = Symbol('drawerContext') as InjectionKey 18 | 19 | export function provideDrawerContext(value: DrawerContextValue) { 20 | return provide(DrawerContext, value) 21 | } 22 | 23 | export const useDrawerContext = () => inject(DrawerContext) 24 | -------------------------------------------------------------------------------- /aioli/src/styles.css: -------------------------------------------------------------------------------- 1 | [aioli-drawer] { 2 | touch-action: none; 3 | transform: translate3d(0, 100%, 0); 4 | transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); 5 | } 6 | 7 | [aioli-drawer][aioli-visible='true'] { 8 | transform: translate3d(0, var(--snap-point-height, 0), 0); 9 | } 10 | 11 | [aioli-overlay] { 12 | opacity: 0; 13 | transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1); 14 | } 15 | 16 | .aioli-dragging .aioli-scrollable { 17 | overflow-y: hidden !important; 18 | } 19 | 20 | [aioli-overlay][aioli-visible='true'] { 21 | opacity: var(--drag-percent, 1); 22 | } 23 | 24 | [aioli-drawer]::after { 25 | content: ''; 26 | position: absolute; 27 | top: 100%; 28 | background: inherit; 29 | background-color: inherit; 30 | left: 0; 31 | right: 0; 32 | height: 200%; 33 | } 34 | 35 | @media (hover: hover) and (pointer: fine) { 36 | [aioli-drawer] { 37 | user-select: none; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /aioli/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import dts from 'vite-plugin-dts' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | vue(), 9 | dts({ 10 | cleanVueFileName: true, 11 | }), 12 | ], 13 | build: { 14 | cssCodeSplit: true, 15 | lib: { 16 | name: 'aioli', 17 | entry: ['src/index.ts', 'src/nuxt.ts', 'src/styles.css'], 18 | }, 19 | minify: false, 20 | rollupOptions: { 21 | // make sure to externalize deps that shouldn't be bundled 22 | // into your library (Vue) 23 | external: ['vue', 'radix-vue', '@nuxt/kit'], 24 | output: { 25 | // Provide global variables to use in the UMD build 26 | // for externalized deps 27 | globals: { 28 | vue: 'Vue', 29 | }, 30 | assetFileNames: (chunkInfo) => { 31 | return chunkInfo.name as string 32 | }, 33 | }, 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aioli-monorepo", 3 | "private": true, 4 | "packageManager": "pnpm@8.13.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "pnpm --filter './website' dev", 8 | "typecheck": "pnpm --filter '*' typecheck", 9 | "lint": "npm run lint:files aioli website --", 10 | "lint:fix": "npm run lint -- --fix", 11 | "lint:files": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --max-warnings 0", 12 | "build": "pnpm --filter './aioli' build", 13 | "build:website": "pnpm --filter './website' generate", 14 | "release": "pnpm --filter=aioli exec bumpp" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^20.10.6", 18 | "@vue/eslint-config-typescript": "^12.0.0", 19 | "bumpp": "^9.4.2", 20 | "eslint": "^8.56.0", 21 | "eslint-config-prettier": "^9.1.0", 22 | "eslint-plugin-prettier": "^5.1.2", 23 | "eslint-plugin-vue": "^9.19.2", 24 | "prettier": "^3.1.1", 25 | "typescript": "^5.5.4", 26 | "vue-tsc": "^2.0.29" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/main-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Main-CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | checks: 9 | uses: ./.github/workflows/checks.yaml 10 | 11 | website: 12 | runs-on: ubuntu-latest 13 | needs: checks 14 | permissions: 15 | id-token: write 16 | contents: write 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: Setup node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: lts/* 30 | cache: pnpm 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | - name: Install Dependencies 34 | run: pnpm i 35 | 36 | - name: Build Website 37 | run: pnpm run build:website 38 | 39 | - name: Github Pages 40 | uses: peaceiris/actions-gh-pages@v3 41 | with: 42 | github_token: ${{ secrets.GITHUB_TOKEN }} 43 | publish_dir: ./website/dist 44 | 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Maik Kowol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write 13 | contents: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: Setup node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: lts/* 27 | cache: pnpm 28 | registry-url: 'https://registry.npmjs.org' 29 | 30 | - name: Install Dependencies 31 | run: pnpm i 32 | 33 | - name: Generate Changelog 34 | run: pnpm dlx changelogithub 35 | env: 36 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 37 | 38 | - name: Build 39 | run: pnpm run build 40 | 41 | - name: Publish to NPM 42 | run: pnpm -r publish --access public --no-git-checks 43 | env: 44 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 45 | NPM_CONFIG_PROVENANCE: true 46 | -------------------------------------------------------------------------------- /website/app/components/GithubIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /aioli/src/nuxt.ts: -------------------------------------------------------------------------------- 1 | import { addComponent, defineNuxtModule, createResolver } from '@nuxt/kit' 2 | 3 | import type {} from '@nuxt/schema' // workaround for TS bug with "phantom" deps 4 | // import { components as allComponents } from '../../../radix-vue/constant/components' 5 | 6 | export interface ModuleOptions { 7 | prefix: string 8 | installStyles: boolean 9 | } 10 | 11 | function getComponents() { 12 | return [ 13 | 'DialogClose', 14 | 'DialogContent', 15 | 'DialogDescription', 16 | 'DialogOverlay', 17 | 'DialogPortal', 18 | 'DialogRoot', 19 | 'DialogTitle', 20 | 'DialogTrigger', 21 | ] 22 | } 23 | 24 | export default defineNuxtModule({ 25 | meta: { 26 | name: 'aioli/nuxt', 27 | configKey: 'aioli', 28 | compatibility: { 29 | nuxt: '>=3.0.0', 30 | }, 31 | }, 32 | defaults: { 33 | prefix: 'A', 34 | installStyles: true, 35 | }, 36 | setup(options, nuxt) { 37 | const resolver = createResolver(import.meta.url) 38 | for (const component of getComponents()) { 39 | addComponent({ 40 | name: `${options.prefix}${component}`, 41 | export: component, 42 | filePath: resolver.resolve('./index'), 43 | }) 44 | } 45 | 46 | if (options.installStyles) { 47 | nuxt.options.css.push(resolver.resolve('./styles.css')) 48 | } 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aioli 2 | 3 | This is a drawer/bottom-sheet library based on [radix-vue](https://github.com/radix-vue/radix-vue). It is **heavily** inspired by [Vaul](https://github.com/emilkowalski/vaul) and pretty much a Vue port of it. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pnpm add aioli radix-vue 9 | 10 | npm install aioli radix-vue 11 | 12 | yarn add aioli radix-vue 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```vue 18 | 21 | 22 | 33 | ``` 34 | 35 | ## Credits 36 | 37 | - [Vaul](https://github.com/emilkowalski/vaul) for the awesome drawer idea/implementation ❤️ 38 | - [radix-vue](https://github.com/radix-vue/radix-vue) for the Radix UI port 39 | - [VueUse](https://github.com/vueuse/vueuse) 40 | 41 | 42 | ## Why the name? 43 | 44 | Originally I wanted to name it something along the lines of `vaul-vue` but while porting Vaul I realized that I wanted to make some changes to the Implementation and API. Naming it `vaul-vue` seemed a bit misleading then. 45 | 46 | So I decided to name it something else. I couldn't think of anything for a while. Ultimately some friends suggested Aioli and I just went with it. 47 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v4 10 | 11 | - name: Setup pnpm 12 | uses: pnpm/action-setup@v4 13 | 14 | - name: Setup node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: lts/* 18 | cache: pnpm 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Install Dependencies 22 | run: pnpm i 23 | 24 | - name: Build 25 | run: npm run build 26 | 27 | 28 | lint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Setup pnpm 35 | uses: pnpm/action-setup@v4 36 | 37 | - name: Setup node 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: lts/* 41 | cache: pnpm 42 | registry-url: 'https://registry.npmjs.org' 43 | 44 | - name: Install Dependencies 45 | run: pnpm i 46 | 47 | - name: Lint 48 | run: npm run lint 49 | 50 | 51 | typecheck: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v4 56 | 57 | - name: Setup pnpm 58 | uses: pnpm/action-setup@v4 59 | 60 | - name: Setup node 61 | uses: actions/setup-node@v4 62 | with: 63 | node-version: lts/* 64 | cache: pnpm 65 | registry-url: 'https://registry.npmjs.org' 66 | 67 | - name: Install Dependencies 68 | run: pnpm i 69 | 70 | - name: Typecheck 71 | run: npm run typecheck 72 | -------------------------------------------------------------------------------- /aioli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aioli", 3 | "version": "0.3.0", 4 | "description": "vaul-like, radix-vue based bottomsheet/drawer component", 5 | "author": "Maik Kowol", 6 | "license": "MIT", 7 | "homepage": "https://94726.github.io/aioli", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/94726/aioli" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/94726/aioli/issues" 14 | }, 15 | "keywords": [ 16 | "vue", 17 | "drawer", 18 | "bottom sheet", 19 | "dialog", 20 | "drawer", 21 | "vaul" 22 | ], 23 | "type": "module", 24 | "main": "dist/index.js", 25 | "module": "./dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "sideEffects": false, 28 | "files": [ 29 | "dist" 30 | ], 31 | "exports": { 32 | ".": { 33 | "import": "./dist/index.js", 34 | "types": "./dist/index.d.ts" 35 | }, 36 | "./nuxt": { 37 | "import": "./dist/nuxt.js", 38 | "types": "./dist/nuxt.d.ts" 39 | }, 40 | "./styles": { 41 | "import": "./dist/styles.css" 42 | }, 43 | "./dist/*": "./dist/*" 44 | }, 45 | "scripts": { 46 | "build": "vite build", 47 | "typecheck": "vue-tsc --noEmit -p .", 48 | "prepack": "cp ../README.md ./", 49 | "postpack": "rm ./README.md" 50 | }, 51 | "devDependencies": { 52 | "@nuxt/schema": "~3.12.4", 53 | "@vitejs/plugin-vue": "^5.1.2", 54 | "@vueuse/core": "^10.11.0", 55 | "radix-vue": "^1.9.2", 56 | "vite": "^5.3.5", 57 | "vite-plugin-dts": "^3.9.1", 58 | "vue": "^3.4.35" 59 | }, 60 | "dependencies": { 61 | "@nuxt/kit": "~3.12.4" 62 | }, 63 | "peerDependencies": { 64 | "radix-vue": "1.x", 65 | "vue": "3.x" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /aioli/src/Content.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 63 | -------------------------------------------------------------------------------- /aioli/src/helpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | interface Style { 3 | [key: string]: string 4 | } 5 | 6 | const cache = new WeakMap() 7 | 8 | export function isInView(el: HTMLElement): boolean { 9 | const rect = el.getBoundingClientRect() 10 | 11 | if (!window.visualViewport) return false 12 | 13 | return ( 14 | rect.top >= 0 && 15 | rect.left >= 0 && 16 | // Need + 40 for safari detection 17 | rect.bottom <= window.visualViewport.height - 40 && 18 | rect.right <= window.visualViewport.width 19 | ) 20 | } 21 | 22 | export function set(el?: Element | HTMLElement | null, styles?: Style, ignoreCache = false) { 23 | if (!el || !(el instanceof HTMLElement) || !styles) return 24 | const originalStyles: Style = {} 25 | 26 | Object.entries(styles).forEach(([key, value]: [string, string]) => { 27 | if (key.startsWith('--')) { 28 | el.style.setProperty(key, value) 29 | return 30 | } 31 | 32 | originalStyles[key] = (el.style as any)[key] 33 | ;(el.style as any)[key] = value 34 | }) 35 | 36 | if (ignoreCache) return 37 | 38 | cache.set(el, originalStyles) 39 | } 40 | 41 | export function reset(el: Element | HTMLElement | null, prop?: string) { 42 | if (!el || !(el instanceof HTMLElement)) return 43 | const originalStyles = cache.get(el) 44 | 45 | if (!originalStyles) { 46 | return 47 | } 48 | 49 | if (prop) { 50 | ;(el.style as any)[prop] = originalStyles[prop] 51 | } else { 52 | Object.entries(originalStyles).forEach(([key, value]) => { 53 | ;(el.style as any)[key] = value 54 | }) 55 | } 56 | } 57 | 58 | export function getTranslateY(element: HTMLElement): number | null { 59 | const style = window.getComputedStyle(element) 60 | const transform = 61 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 62 | // @ts-ignore 63 | style.transform || style.webkitTransform || style.mozTransform 64 | let mat = transform.match(/^matrix3d\((.+)\)$/) 65 | if (mat) return parseFloat(mat[1].split(', ')[13]) 66 | mat = transform.match(/^matrix\((.+)\)$/) 67 | return mat ? parseFloat(mat[1].split(', ')[5]) : null 68 | } 69 | 70 | export function dampenValue(v: number) { 71 | return Math.sign(v) * 8 * (Math.log(Math.abs(v) + 1) - 2) 72 | } 73 | 74 | const nonTextInputTypes = new Set(['checkbox', 'radio', 'range', 'color', 'file', 'image', 'button', 'submit', 'reset']) 75 | export function isInput(target: Element | EventTarget | null): target is HTMLInputElement | HTMLTextAreaElement { 76 | return ( 77 | (target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type)) || 78 | target instanceof HTMLTextAreaElement || 79 | (target instanceof HTMLElement && target.isContentEditable) 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /website/app/app.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 90 | -------------------------------------------------------------------------------- /website/app/components/example/ExampleSystemTray.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 247 | 248 | 257 | -------------------------------------------------------------------------------- /aioli/src/Root.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 408 | -------------------------------------------------------------------------------- /website/app/auto-animate.ts: -------------------------------------------------------------------------------- 1 | // necessary until https://github.com/formkit/auto-animate/pull/211 gets merged 2 | /* eslint-disable */ 3 | // @ts-nocheck 4 | 5 | /** 6 | * Absolute coordinate positions adjusted for scroll. 7 | */ 8 | interface Coordinates { 9 | top: number 10 | left: number 11 | width: number 12 | height: number 13 | } 14 | 15 | /** 16 | * Allows start/stop control of the animation 17 | */ 18 | export interface AnimationController

{ 19 | /** 20 | * The original animation parent. 21 | */ 22 | readonly parent: Element 23 | /** 24 | * A function that enables future animations. 25 | */ 26 | enable: () => void 27 | /** 28 | * A function that disables future animations. 29 | */ 30 | disable: () => void 31 | /** 32 | * A function that returns true if the animations are currently enabled. 33 | */ 34 | isEnabled: () => boolean 35 | /** 36 | * (Svelte Specific) A function that runs if the parameters are changed. 37 | */ 38 | update?: (newParams: P) => void 39 | /** 40 | * (Svelte Specific) A function that runs when the component is removed from the DOM. 41 | */ 42 | destroy?: () => void 43 | } 44 | 45 | /** 46 | * A set of all the parents currently being observe. This is the only non weak 47 | * registry. 48 | */ 49 | const parents = new Set() 50 | /** 51 | * Element coordinates that is constantly kept up to date. 52 | */ 53 | const coords = new WeakMap() 54 | /** 55 | * Siblings of elements that have been removed from the dom. 56 | */ 57 | const siblings = new WeakMap() 58 | /** 59 | * Animations that are currently running. 60 | */ 61 | const animations = new WeakMap() 62 | /** 63 | * A map of existing intersection observers used to track element movements. 64 | */ 65 | const intersections = new WeakMap() 66 | /** 67 | * Intervals for automatically checking the position of elements occasionally. 68 | */ 69 | const intervals = new WeakMap() 70 | /** 71 | * The configuration options for each group of elements. 72 | */ 73 | const options = new WeakMap() 74 | /** 75 | * Debounce counters by id, used to debounce calls to update positions. 76 | */ 77 | const debounces = new WeakMap() 78 | /** 79 | * All parents that are currently enabled are tracked here. 80 | */ 81 | const enabled = new WeakSet() 82 | /** 83 | * The document used to calculate transitions. 84 | */ 85 | let root: HTMLElement 86 | 87 | /** 88 | * The root’s XY scroll positions. 89 | */ 90 | let scrollX = 0 91 | let scrollY = 0 92 | /** 93 | * Used to sign an element as the target. 94 | */ 95 | const TGT = '__aa_tgt' 96 | /** 97 | * Used to sign an element as being part of a removal. 98 | */ 99 | const DEL = '__aa_del' 100 | /** 101 | * Used to sign an element as being "new". When an element is removed from the 102 | * dom, but may cycle back in we can sign it with new to ensure the next time 103 | * it is recognized we consider it new. 104 | */ 105 | const NEW = '__aa_new' 106 | 107 | /** 108 | * Callback for handling all mutations. 109 | * @param mutations - A mutation list 110 | */ 111 | const handleMutations: MutationCallback = (mutations) => { 112 | const elements = getElements(mutations) 113 | // If elements is "false" that means this mutation that should be ignored. 114 | if (elements) { 115 | elements.forEach((el) => animate(el)) 116 | } 117 | } 118 | 119 | /** 120 | * 121 | * @param entries - Elements that have been resized. 122 | */ 123 | const handleResizes: ResizeObserverCallback = (entries) => { 124 | entries.forEach((entry) => { 125 | if (entry.target === root) updateAllPos() 126 | if (coords.has(entry.target)) updatePos(entry.target) 127 | }) 128 | } 129 | 130 | /** 131 | * Observe this elements position. 132 | * @param el - The element to observe the position of. 133 | */ 134 | function observePosition(el: Element) { 135 | const oldObserver = intersections.get(el) 136 | oldObserver?.disconnect() 137 | let rect = coords.get(el) 138 | let invocations = 0 139 | const buffer = 5 140 | if (!rect) { 141 | rect = getCoords(el) 142 | coords.set(el, rect) 143 | } 144 | const { offsetWidth, offsetHeight } = root 145 | const rootMargins = [ 146 | rect.top - buffer, 147 | offsetWidth - (rect.left + buffer + rect.width), 148 | offsetHeight - (rect.top + buffer + rect.height), 149 | rect.left - buffer, 150 | ] 151 | const rootMargin = rootMargins.map((px) => `${-1 * Math.floor(px)}px`).join(' ') 152 | const observer = new IntersectionObserver( 153 | () => { 154 | ++invocations > 1 && updatePos(el) 155 | }, 156 | { 157 | root, 158 | threshold: 1, 159 | rootMargin, 160 | }, 161 | ) 162 | observer.observe(el) 163 | intersections.set(el, observer) 164 | } 165 | 166 | /** 167 | * Update the exact position of a given element. 168 | * @param el - An element to update the position of. 169 | * @param debounce - Whether or not to debounce the update. After an animation is finished, it should update as soon as possible to prevent flickering on quick toggles. 170 | */ 171 | function updatePos(el: Element, debounce = true) { 172 | clearTimeout(debounces.get(el)) 173 | const optionsOrPlugin = getOptions(el) 174 | const delay = debounce ? (isPlugin(optionsOrPlugin) ? 500 : optionsOrPlugin.duration) : 0 175 | debounces.set( 176 | el, 177 | setTimeout(async () => { 178 | const currentAnimation = animations.get(el) 179 | 180 | try { 181 | await currentAnimation?.finished 182 | 183 | coords.set(el, getCoords(el)) 184 | observePosition(el) 185 | } catch { 186 | // ignore errors as the `.finished` promise is rejected when animations were cancelled 187 | } 188 | }, delay), 189 | ) 190 | } 191 | 192 | /** 193 | * Updates all positions that are currently being tracked. 194 | */ 195 | function updateAllPos() { 196 | clearTimeout(debounces.get(root)) 197 | debounces.set( 198 | root, 199 | setTimeout(() => { 200 | parents.forEach((parent) => forEach(parent, (el) => lowPriority(() => updatePos(el)))) 201 | }, 100), 202 | ) 203 | } 204 | 205 | /** 206 | * Its possible for a quick scroll or other fast events to get past the 207 | * intersection observer, so occasionally we need want "cold-poll" for the 208 | * latests and greatest position. We try to do this in the most non-disruptive 209 | * fashion possible. First we only do this ever couple seconds, staggard by a 210 | * random offset. 211 | * @param el - Element 212 | */ 213 | function poll(el: Element) { 214 | setTimeout( 215 | () => { 216 | intervals.set( 217 | el, 218 | setInterval(() => lowPriority(updatePos.bind(null, el)), 2000), 219 | ) 220 | }, 221 | Math.round(2000 * Math.random()), 222 | ) 223 | } 224 | 225 | /** 226 | * Perform some operation that is non critical at some point. 227 | * @param callback 228 | */ 229 | function lowPriority(callback: CallableFunction) { 230 | if (typeof requestIdleCallback === 'function') { 231 | requestIdleCallback(() => callback()) 232 | } else { 233 | requestAnimationFrame(() => callback()) 234 | } 235 | } 236 | 237 | /** 238 | * The mutation observer responsible for watching each root element. 239 | */ 240 | let mutations: MutationObserver | undefined 241 | 242 | /** 243 | * A resize observer, responsible for recalculating elements on resize. 244 | */ 245 | let resize: ResizeObserver | undefined 246 | 247 | /** 248 | * Ensure the browser is supported. 249 | */ 250 | const supportedBrowser = typeof window !== 'undefined' && 'ResizeObserver' in window 251 | 252 | /** 253 | * If this is in a browser, initialize our Web APIs 254 | */ 255 | if (supportedBrowser) { 256 | root = document.documentElement 257 | mutations = new MutationObserver(handleMutations) 258 | resize = new ResizeObserver(handleResizes) 259 | window.addEventListener('scroll', () => { 260 | scrollY = window.scrollY 261 | scrollX = window.scrollX 262 | }) 263 | resize.observe(root) 264 | } 265 | /** 266 | * Retrieves all the elements that may have been affected by the last mutation 267 | * including ones that have been removed and are no longer in the DOM. 268 | * @param mutations - A mutation list. 269 | * @returns 270 | */ 271 | function getElements(mutations: MutationRecord[]): Set | false { 272 | const observedNodes = mutations.reduce((nodes: Node[], mutation) => { 273 | return [...nodes, ...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)] 274 | }, []) 275 | // Short circuit if _only_ comment nodes are observed 276 | const onlyCommentNodesObserved = observedNodes.every((node) => node.nodeName === '#comment') 277 | 278 | if (onlyCommentNodesObserved) return false 279 | 280 | return mutations.reduce((elements: Set | false, mutation) => { 281 | // Short circuit if we find a purposefully deleted node. 282 | if (elements === false) return false 283 | 284 | if (mutation.target instanceof Element) { 285 | target(mutation.target) 286 | if (!elements.has(mutation.target)) { 287 | elements.add(mutation.target) 288 | for (let i = 0; i < mutation.target.children.length; i++) { 289 | const child = mutation.target.children.item(i) 290 | if (!child) continue 291 | if (DEL in child) { 292 | return false 293 | } 294 | target(mutation.target, child) 295 | elements.add(child) 296 | } 297 | } 298 | if (mutation.removedNodes.length) { 299 | for (let i = 0; i < mutation.removedNodes.length; i++) { 300 | const child = mutation.removedNodes[i] 301 | if (DEL in child) { 302 | return false 303 | } 304 | if (child instanceof Element) { 305 | elements.add(child) 306 | target(mutation.target, child) 307 | siblings.set(child, [mutation.previousSibling, mutation.nextSibling]) 308 | } 309 | } 310 | } 311 | } 312 | return elements 313 | }, new Set()) 314 | } 315 | 316 | /** 317 | * Assign the target to an element. 318 | * @param el - The root element 319 | * @param child 320 | */ 321 | function target(el: Element, child?: Element) { 322 | if (!child && !(TGT in el)) Object.defineProperty(el, TGT, { value: el }) 323 | else if (child && !(TGT in child)) Object.defineProperty(child, TGT, { value: el }) 324 | } 325 | 326 | /** 327 | * Determines what kind of change took place on the given element and then 328 | * performs the proper animation based on that. 329 | * @param el - The specific element to animate. 330 | */ 331 | function animate(el: Element) { 332 | const isMounted = el.isConnected 333 | const preExisting = coords.has(el) 334 | if (isMounted && siblings.has(el)) siblings.delete(el) 335 | 336 | if (animations.get(el)?.playState !== 'finished') { 337 | animations.get(el)?.cancel() 338 | } 339 | if (NEW in el) { 340 | add(el) 341 | } else if (preExisting && isMounted) { 342 | remain(el) 343 | } else if (preExisting && !isMounted) { 344 | remove(el) 345 | } else { 346 | add(el) 347 | } 348 | } 349 | 350 | /** 351 | * Removes all non-digits from a string and casts to a number. 352 | * @param str - A string containing a pixel value. 353 | * @returns 354 | */ 355 | function raw(str: string): number { 356 | return Number(str.replace(/[^0-9.\-]/g, '')) 357 | } 358 | 359 | /** 360 | * Get the scroll offset of elements 361 | * @param el - Element 362 | * @returns 363 | */ 364 | function getScrollOffset(el: Element) { 365 | let p = el.parentElement 366 | while (p) { 367 | if (p.scrollLeft || p.scrollTop) { 368 | return { x: p.scrollLeft, y: p.scrollTop } 369 | } 370 | p = p.parentElement 371 | } 372 | return { x: 0, y: 0 } 373 | } 374 | 375 | /** 376 | * Get the coordinates of elements adjusted for scroll position. 377 | * @param el - Element 378 | * @returns 379 | */ 380 | function getCoords(el: Element): Coordinates { 381 | const rect = el.getBoundingClientRect() 382 | const { x, y } = getScrollOffset(el) 383 | return { 384 | top: rect.top + y, 385 | left: rect.left + x, 386 | width: rect.width, 387 | height: rect.height, 388 | } 389 | } 390 | 391 | /** 392 | * Returns the width/height that the element should be transitioned between. 393 | * This takes into account box-sizing. 394 | * @param el - Element being animated 395 | * @param oldCoords - Old set of Coordinates coordinates 396 | * @param newCoords - New set of Coordinates coordinates 397 | * @returns 398 | */ 399 | export function getTransitionSizes(el: Element, oldCoords: Coordinates, newCoords: Coordinates) { 400 | let widthFrom = oldCoords.width 401 | let heightFrom = oldCoords.height 402 | let widthTo = newCoords.width 403 | let heightTo = newCoords.height 404 | const styles = getComputedStyle(el) 405 | const sizing = styles.getPropertyValue('box-sizing') 406 | 407 | if (sizing === 'content-box') { 408 | const paddingY = 409 | raw(styles.paddingTop) + raw(styles.paddingBottom) + raw(styles.borderTopWidth) + raw(styles.borderBottomWidth) 410 | const paddingX = 411 | raw(styles.paddingLeft) + raw(styles.paddingRight) + raw(styles.borderRightWidth) + raw(styles.borderLeftWidth) 412 | widthFrom -= paddingX 413 | widthTo -= paddingX 414 | heightFrom -= paddingY 415 | heightTo -= paddingY 416 | } 417 | 418 | return [widthFrom, widthTo, heightFrom, heightTo].map(Math.round) 419 | } 420 | 421 | /** 422 | * Retrieves animation options for the current element. 423 | * @param el - Element to retrieve options for. 424 | * @returns 425 | */ 426 | function getOptions(el: Element): AutoAnimateOptions | AutoAnimationPlugin { 427 | return TGT in el && options.has((el as Element & { __aa_tgt: Element })[TGT]) 428 | ? options.get((el as Element & { __aa_tgt: Element })[TGT])! 429 | : { duration: 250, easing: 'ease-in-out' } 430 | } 431 | 432 | /** 433 | * Returns the target of a given animation (generally the parent). 434 | * @param el - An element to check for a target 435 | * @returns 436 | */ 437 | function getTarget(el: Element): Element | undefined { 438 | if (TGT in el) return (el as Element & { __aa_tgt: Element })[TGT] 439 | return undefined 440 | } 441 | 442 | /** 443 | * Checks if animations are enabled or disabled for a given element. 444 | * @param el - Any element 445 | * @returns 446 | */ 447 | function isEnabled(el: Element): boolean { 448 | const target = getTarget(el) 449 | return target ? enabled.has(target) : false 450 | } 451 | 452 | /** 453 | * Iterate over the children of a given parent. 454 | * @param parent - A parent element 455 | * @param callback - A callback 456 | */ 457 | function forEach(parent: Element, ...callbacks: Array<(el: Element, isRoot?: boolean) => void>) { 458 | callbacks.forEach((callback) => callback(parent, options.has(parent))) 459 | for (let i = 0; i < parent.children.length; i++) { 460 | const child = parent.children.item(i) 461 | if (child) { 462 | callbacks.forEach((callback) => callback(child, options.has(child))) 463 | } 464 | } 465 | } 466 | 467 | /** 468 | * Always return tuple to provide consistent interface 469 | */ 470 | function getPluginTuple( 471 | pluginReturn: ReturnType, 472 | ): [KeyframeEffect, AutoAnimationPluginOptions] | [KeyframeEffect] { 473 | if (Array.isArray(pluginReturn)) return pluginReturn 474 | 475 | return [pluginReturn] 476 | } 477 | 478 | /** 479 | * Determine if config is plugin 480 | */ 481 | function isPlugin(config: Partial | AutoAnimationPlugin): config is AutoAnimationPlugin { 482 | return typeof config === 'function' 483 | } 484 | 485 | /** 486 | * The element in question is remaining in the DOM. 487 | * @param el - Element to flip 488 | * @returns 489 | */ 490 | function remain(el: Element) { 491 | const oldCoords = coords.get(el) 492 | const newCoords = getCoords(el) 493 | if (!isEnabled(el)) return coords.set(el, newCoords) 494 | let animation: Animation 495 | if (!oldCoords) return 496 | const pluginOrOptions = getOptions(el) 497 | if (typeof pluginOrOptions !== 'function') { 498 | let deltaLeft = oldCoords.left - newCoords.left 499 | let deltaTop = oldCoords.top - newCoords.top 500 | const deltaRight = oldCoords.left + oldCoords.width - (newCoords.left + newCoords.width) 501 | const deltaBottom = oldCoords.top + oldCoords.height - (newCoords.top + newCoords.height) 502 | 503 | // element is probably anchored and doesn't need to be offset 504 | if (deltaBottom == 0) deltaTop = 0 505 | if (deltaRight == 0) deltaLeft = 0 506 | 507 | const [widthFrom, widthTo, heightFrom, heightTo] = getTransitionSizes(el, oldCoords, newCoords) 508 | const start: Record = { 509 | transform: `translate(${deltaLeft}px, ${deltaTop}px)`, 510 | } 511 | const end: Record = { 512 | transform: `translate(0, 0)`, 513 | } 514 | if (widthFrom !== widthTo) { 515 | start.width = `${widthFrom}px` 516 | end.width = `${widthTo}px` 517 | } 518 | if (heightFrom !== heightTo) { 519 | start.height = `${heightFrom}px` 520 | end.height = `${heightTo}px` 521 | } 522 | animation = el.animate([start, end], { 523 | duration: pluginOrOptions.duration, 524 | easing: pluginOrOptions.easing, 525 | }) 526 | } else { 527 | const [keyframes] = getPluginTuple(pluginOrOptions(el, 'remain', oldCoords, newCoords)) 528 | animation = new Animation(keyframes) 529 | animation.play() 530 | } 531 | animations.set(el, animation) 532 | coords.set(el, newCoords) 533 | animation.addEventListener('finish', updatePos.bind(null, el, false)) 534 | } 535 | 536 | /** 537 | * Adds the element with a transition. 538 | * @param el - Animates the element being added. 539 | */ 540 | function add(el: Element) { 541 | if (NEW in el) delete el[NEW] 542 | const newCoords = getCoords(el) 543 | coords.set(el, newCoords) 544 | const pluginOrOptions = getOptions(el) 545 | if (!isEnabled(el)) return 546 | let animation: Animation 547 | if (typeof pluginOrOptions !== 'function') { 548 | animation = el.animate( 549 | [ 550 | { transform: 'scale(.98)', opacity: 0 }, 551 | { transform: 'scale(0.98)', opacity: 0, offset: 0.5 }, 552 | { transform: 'scale(1)', opacity: 1 }, 553 | ], 554 | { 555 | duration: pluginOrOptions.duration * 1.5, 556 | easing: 'ease-in', 557 | }, 558 | ) 559 | } else { 560 | const [keyframes] = getPluginTuple(pluginOrOptions(el, 'add', newCoords)) 561 | animation = new Animation(keyframes) 562 | animation.play() 563 | } 564 | animations.set(el, animation) 565 | animation.addEventListener('finish', updatePos.bind(null, el, false)) 566 | } 567 | 568 | /** 569 | * Clean up after removing an element from the dom. 570 | * @param el - Element being removed 571 | * @param styles - Optional styles that should be removed from the element. 572 | */ 573 | function cleanUp(el: Element, styles?: Partial) { 574 | el.remove() 575 | coords.delete(el) 576 | siblings.delete(el) 577 | animations.delete(el) 578 | intersections.get(el)?.disconnect() 579 | setTimeout(() => { 580 | if (DEL in el) delete el[DEL] 581 | Object.defineProperty(el, NEW, { value: true, configurable: true }) 582 | if (styles && el instanceof HTMLElement) { 583 | for (const style in styles) { 584 | el.style[style as any] = '' 585 | } 586 | } 587 | }, 0) 588 | } 589 | 590 | /** 591 | * Animates the removal of an element. 592 | * @param el - Element to remove 593 | */ 594 | function remove(el: Element) { 595 | if (!siblings.has(el) || !coords.has(el)) return 596 | 597 | const [prev, next] = siblings.get(el)! 598 | Object.defineProperty(el, DEL, { value: true, configurable: true }) 599 | const finalX = window.scrollX 600 | const finalY = window.scrollY 601 | 602 | if (next && next.parentNode && next.parentNode instanceof Element) { 603 | next.parentNode.insertBefore(el, next) 604 | } else if (prev && prev.parentNode) { 605 | prev.parentNode.appendChild(el) 606 | } else { 607 | getTarget(el)?.appendChild(el) 608 | } 609 | if (!isEnabled(el)) return cleanUp(el) 610 | 611 | const [top, left, width, height] = deletePosition(el) 612 | const optionsOrPlugin = getOptions(el) 613 | const oldCoords = coords.get(el)! 614 | if (finalX !== scrollX || finalY !== scrollY) { 615 | adjustScroll(el, finalX, finalY, optionsOrPlugin) 616 | } 617 | 618 | let animation: Animation 619 | let styleReset: Partial = { 620 | position: 'absolute', 621 | top: `${top}px`, 622 | left: `${left}px`, 623 | width: `${width}px`, 624 | height: `${height}px`, 625 | margin: '0', 626 | pointerEvents: 'none', 627 | transformOrigin: 'center', 628 | zIndex: '100', 629 | } 630 | 631 | if (!isPlugin(optionsOrPlugin)) { 632 | Object.assign((el as HTMLElement).style, styleReset) 633 | animation = el.animate( 634 | [ 635 | { 636 | transform: 'scale(1)', 637 | opacity: 1, 638 | }, 639 | { 640 | transform: 'scale(.98)', 641 | opacity: 0, 642 | }, 643 | ], 644 | { duration: optionsOrPlugin.duration, easing: 'ease-out' }, 645 | ) 646 | } else { 647 | const [keyframes, options] = getPluginTuple(optionsOrPlugin(el, 'remove', oldCoords)) 648 | if (options?.styleReset !== false) { 649 | styleReset = options?.styleReset || styleReset 650 | Object.assign((el as HTMLElement).style, styleReset) 651 | } 652 | animation = new Animation(keyframes) 653 | animation.play() 654 | } 655 | animations.set(el, animation) 656 | animation.addEventListener('finish', cleanUp.bind(null, el, styleReset)) 657 | } 658 | 659 | /** 660 | * If the element being removed is at the very bottom of the page, and the 661 | * the page was scrolled into a space being "made available" by the element 662 | * that was removed, the page scroll will have jumped up some amount. We need 663 | * to offset the jump by the amount that the page was "automatically" scrolled 664 | * up. We can do this by comparing the scroll position before and after the 665 | * element was removed, and then offsetting by that amount. 666 | * 667 | * @param el - The element being deleted 668 | * @param finalX - The final X scroll position 669 | * @param finalY - The final Y scroll position 670 | * @param optionsOrPlugin - The options or plugin 671 | * @returns 672 | */ 673 | function adjustScroll( 674 | el: Element, 675 | finalX: number, 676 | finalY: number, 677 | optionsOrPlugin: AutoAnimateOptions | AutoAnimationPlugin, 678 | ) { 679 | const scrollDeltaX = scrollX - finalX 680 | const scrollDeltaY = scrollY - finalY 681 | const scrollBefore = document.documentElement.style.scrollBehavior 682 | const scrollBehavior = getComputedStyle(root).scrollBehavior 683 | if (scrollBehavior === 'smooth') { 684 | document.documentElement.style.scrollBehavior = 'auto' 685 | } 686 | window.scrollTo(window.scrollX + scrollDeltaX, window.scrollY + scrollDeltaY) 687 | if (!el.parentElement) return 688 | const parent = el.parentElement 689 | let lastHeight = parent.clientHeight 690 | let lastWidth = parent.clientWidth 691 | const startScroll = performance.now() 692 | // Here we use a manual scroll animation to keep the element using the same 693 | // easing and timing as the parent’s scroll animation. 694 | function smoothScroll() { 695 | requestAnimationFrame(() => { 696 | if (!isPlugin(optionsOrPlugin)) { 697 | const deltaY = lastHeight - parent.clientHeight 698 | const deltaX = lastWidth - parent.clientWidth 699 | if (startScroll + optionsOrPlugin.duration > performance.now()) { 700 | window.scrollTo({ 701 | left: window.scrollX - deltaX!, 702 | top: window.scrollY - deltaY!, 703 | }) 704 | lastHeight = parent.clientHeight 705 | lastWidth = parent.clientWidth 706 | smoothScroll() 707 | } else { 708 | document.documentElement.style.scrollBehavior = scrollBefore 709 | } 710 | } 711 | }) 712 | } 713 | smoothScroll() 714 | } 715 | 716 | /** 717 | * Determines the position of the element being removed. 718 | * @param el - The element being deleted 719 | * @returns 720 | */ 721 | function deletePosition(el: Element): [top: number, left: number, width: number, height: number] { 722 | const oldCoords = coords.get(el)! 723 | const [width, , height] = getTransitionSizes(el, oldCoords, getCoords(el)) 724 | 725 | let offsetParent: Element | null = el.parentElement 726 | while ( 727 | offsetParent && 728 | (getComputedStyle(offsetParent).position === 'static' || offsetParent instanceof HTMLBodyElement) 729 | ) { 730 | offsetParent = offsetParent.parentElement 731 | } 732 | if (!offsetParent) offsetParent = document.body 733 | const parentStyles = getComputedStyle(offsetParent) 734 | const parentCoords = 735 | !animations.has(el) || animations.get(el)?.playState === 'finished' 736 | ? getCoords(offsetParent) 737 | : coords.get(offsetParent)! 738 | 739 | const top = Math.round(oldCoords.top - parentCoords.top) - raw(parentStyles.borderTopWidth) 740 | const left = Math.round(oldCoords.left - parentCoords.left) - raw(parentStyles.borderLeftWidth) 741 | return [top, left, width, height] 742 | } 743 | 744 | export interface AutoAnimateOptions { 745 | /** 746 | * The time it takes to run a single sequence of animations in milliseconds. 747 | */ 748 | duration: number 749 | /** 750 | * The type of easing to use. 751 | * Default: ease-in-out 752 | */ 753 | easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | ({} & string) 754 | /** 755 | * Ignore a user’s "reduce motion" setting and enable animations. It is not 756 | * recommended to use this. 757 | */ 758 | disrespectUserMotionPreference?: boolean 759 | } 760 | 761 | /** 762 | * A custom plugin config object 763 | */ 764 | export interface AutoAnimationPluginOptions { 765 | // provide your own css styles or disable style reset 766 | styleReset: CSSStyleDeclaration | false 767 | } 768 | 769 | /** 770 | * A custom plugin that determines what the effects to run 771 | */ 772 | export interface AutoAnimationPlugin { 773 | ( 774 | el: Element, 775 | action: T, 776 | newCoordinates?: T extends 'add' | 'remain' | 'remove' ? Coordinates : undefined, 777 | oldCoordinates?: T extends 'remain' ? Coordinates : undefined, 778 | ): KeyframeEffect | [KeyframeEffect, AutoAnimationPluginOptions] 779 | } 780 | 781 | /** 782 | * A function that automatically adds animation effects to itself and its 783 | * immediate children. Specifically it adds effects for adding, moving, and 784 | * removing DOM elements. 785 | * @param el - A parent element to add animations to. 786 | * @param options - An optional object of options. 787 | */ 788 | export default function autoAnimate( 789 | el: HTMLElement, 790 | config: Partial | AutoAnimationPlugin = {}, 791 | ): AnimationController { 792 | if (mutations && resize) { 793 | const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') 794 | const isDisabledDueToReduceMotion = 795 | mediaQuery.matches && !isPlugin(config) && !config.disrespectUserMotionPreference 796 | if (!isDisabledDueToReduceMotion) { 797 | enabled.add(el) 798 | if (getComputedStyle(el).position === 'static') { 799 | Object.assign(el.style, { position: 'relative' }) 800 | } 801 | forEach(el, updatePos, poll, (element) => resize?.observe(element)) 802 | if (isPlugin(config)) { 803 | options.set(el, config) 804 | } else { 805 | options.set(el, { duration: 250, easing: 'ease-in-out', ...config }) 806 | } 807 | mutations.observe(el, { childList: true }) 808 | parents.add(el) 809 | } 810 | } 811 | return Object.freeze({ 812 | parent: el, 813 | enable: () => { 814 | enabled.add(el) 815 | }, 816 | disable: () => { 817 | enabled.delete(el) 818 | }, 819 | isEnabled: () => enabled.has(el), 820 | }) 821 | } 822 | 823 | /** 824 | * The vue directive. 825 | */ 826 | export const vAutoAnimate = { 827 | mounted: ( 828 | el: HTMLElement, 829 | binding: { 830 | value: Partial | AutoAnimationPlugin | undefined 831 | }, 832 | ) => { 833 | autoAnimate(el, binding.value || {}) 834 | }, 835 | // ignore ssr see #96: 836 | getSSRProps: () => ({}), 837 | } 838 | --------------------------------------------------------------------------------