├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmrc ├── .vscode └── extensions.json ├── CHANGELOG-CN.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── App.vue ├── CustomContent.vue ├── ExamplesIndex.vue └── main.js ├── index.html ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── Dropdown.jsx ├── DropdownContent.jsx ├── DropdownContentContainer.jsx ├── DropdownTrigger.jsx ├── __tests__ │ ├── components │ │ ├── DropdownCore.jsx │ │ ├── PropsToDropdownContent.vue │ │ └── PropsToDropdownTrigger.vue │ ├── core.spec.js │ ├── events.spec.js │ ├── methods.spec.js │ ├── props-content.spec.js │ ├── props-trigger.spec.js │ ├── props.spec.js │ ├── setup.js │ ├── slot-data.spec.js │ └── util.js ├── constants.js ├── core.js ├── helper.js ├── index.js ├── styles │ ├── _animate.sass │ ├── dropdown.sass │ └── trigger.sass ├── use.js └── util.js ├── tsconfig.json ├── types ├── content.d.ts ├── dropdown.d.ts └── index.d.ts └── vite.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | branches: 9 | only: master 10 | 11 | docker: 12 | # specify the version you desire here 13 | - image: cimg/node:18.20-browsers 14 | 15 | # Specify service dependencies here if necessary 16 | # CircleCI maintains a library of pre-built images 17 | # documented at https://circleci.com/docs/2.0/circleci-images/ 18 | # - image: circleci/mongo:3.4.4 19 | 20 | working_directory: ~/repo 21 | 22 | steps: 23 | - checkout 24 | 25 | # Download and cache dependencies 26 | - restore_cache: 27 | keys: 28 | - v1-dependencies-{{ checksum "package.json" }} 29 | # fallback to using the latest cache if no exact match is found 30 | - v1-dependencies- 31 | 32 | - run: npm install 33 | # - run: npm rebuild node-sass 34 | - run: sudo npm install -g codecov 35 | 36 | - save_cache: 37 | paths: 38 | - node_modules 39 | key: v1-dependencies-{{ checksum "package.json" }} 40 | 41 | # run tests! 42 | - run: npm run coverage 43 | - run: codecov upload-process -t $CODECOV_TOKEN 44 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | const path = require('node:path') 5 | const createAliasSetting = require('@vue/eslint-config-standard/createAliasSetting') 6 | 7 | module.exports = { 8 | root: true, 9 | extends: [ 10 | 'plugin:vue/vue3-strongly-recommended', 11 | '@vue/eslint-config-standard' 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | ecmaFeatures: { 16 | jsx: true 17 | } 18 | }, 19 | ignorePatterns: [ 20 | 'types/' 21 | ], 22 | settings: { 23 | ...createAliasSetting({ 24 | '@': `${path.resolve(__dirname, './src')}` 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: test 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | - release 11 | # release: 12 | # types: [published] 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: '22.x' 23 | cache: pnpm 24 | 25 | - run: pnpm i --frozen-lockfile 26 | - run: pnpm lint 27 | - run: pnpm coverage 28 | 29 | - name: Upload coverage to Codecov 30 | uses: codecov/codecov-action@v5 31 | with: 32 | fail_ci_if_error: true # optional (default = false) 33 | files: ./coverage1.xml,./coverage2.xml # optional 34 | flags: unittests # optional 35 | name: codecov-umbrella # optional 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | verbose: true # optional (default = false) 38 | 39 | publish-npm: 40 | needs: test 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: write 44 | id-token: write 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: pnpm/action-setup@v4 48 | - uses: actions/setup-node@v4 49 | with: 50 | node-version: '22.x' 51 | registry-url: https://registry.npmjs.org/ 52 | cache: pnpm 53 | 54 | - run: pnpm i --frozen-lockfile 55 | - run: pnpm build 56 | - name: Publish to npm 57 | run: npm publish --provenance --access public 58 | env: 59 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | 61 | - name: Get version from package.json 62 | id: version 63 | run: | 64 | VERSION=$(jq -r .version package.json) 65 | echo "version=$VERSION" >> $GITHUB_OUTPUT 66 | - name: Create Release and Tag 67 | uses: softprops/action-gh-release@v2 68 | with: 69 | name: "v${{ steps.version.outputs.version }}" 70 | tag_name: "v${{ steps.version.outputs.version }}" 71 | body: | 72 | Please refer to [CHANGELOG.md](https://github.com/TerryZ/v-dropdown/blob/dev/CHANGELOG.md) for details. 73 | draft: false 74 | prerelease: false 75 | -------------------------------------------------------------------------------- /.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 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .nyc_output/ 16 | coverage/ 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG-CN.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 英文 changelog 内容请访问 [CHANGELOG](CHANGELOG.md) 4 | 5 | ## [3.5.1](https://github.com/TerryZ/v-dropdown/compare/v3.5.0...v3.5.1) (2025-09-24) 6 | 7 | ### 问题修复 8 | 9 | - `align` 对齐方向设置无效 10 | 11 | ## [3.5.0](https://github.com/TerryZ/v-dropdown/compare/v3.4.0...v3.5.0) (2025-09-23) 12 | 13 | ### 新特性 14 | 15 | - `DropdownContent` 移除 `animated` prop 16 | - `Dropdown` 组件添加 `animated` prop,用于设置下拉层打开时是否使用过渡动画 17 | - 重构部分核心实现,优化性能 18 | - 下拉层打开时,添加边缘溢出处理 19 | - 不指定 `trigger` 插槽内容时,默认应用内置的 `DropdownTrigger` 组件 20 | 21 | ## [3.4.0](https://github.com/TerryZ/v-dropdown/compare/v3.3.0...v3.4.0) (2025-03-14) 22 | 23 | ### 新特性 24 | 25 | - `Dropdown` 组件添加 `appendTo` prop,用于指定下拉容器挂载到哪个元素中 26 | 27 | ## [3.3.0](https://github.com/TerryZ/v-dropdown/compare/v3.2.1...v3.3.0) (2025-03-08) 28 | 29 | ### 新特性 30 | 31 | - `DropdownContent` 组件移除 `animationName` prop 32 | 33 | ### 问题修复 34 | 35 | - 修复打开/关闭下拉栏过渡动画失效的问题 36 | 37 | ## [3.2.1](https://github.com/TerryZ/v-dropdown/compare/v3.2.0...v3.2.1) (2025-03-05) 38 | 39 | ### 问题修复 40 | 41 | - 修复开发过程代码热更新后,resize observer 处理目标元素不存在问题 42 | 43 | ## [3.2.0](https://github.com/TerryZ/v-dropdown/compare/v3.1.3...v3.2.0) (2025-03-03) 44 | 45 | ### 新特性 46 | 47 | - 插槽数据与 `useDropdown` 工具函数增加 `adjust` 函数 48 | - 触发器位置与高度发生变化时,自动调整下拉栏位置 49 | 50 | ## [3.1.3](https://github.com/TerryZ/v-dropdown/compare/v3.1.2...v3.1.3) (2025-02-24) 51 | 52 | ### 问题修复 53 | 54 | - 单元测试 `ResizeObserver is not defined` 修复 55 | 56 | ## [3.1.2](https://github.com/TerryZ/v-dropdown/compare/v3.1.1...v3.1.2) (2025-02-24) 57 | 58 | ### 新特性 59 | 60 | - 优化 `ResizeObserver` 兼容性与 SSR 编译问题处理 61 | 62 | ## [3.1.1](https://github.com/TerryZ/v-dropdown/compare/v3.1.0...v3.1.1) (2025-02-23) 63 | 64 | ### 新特性 65 | 66 | - 下拉栏容器添加最大宽度限制 67 | - `ResizeObserver` 兼容性处理 68 | 69 | ## [3.1.0](https://github.com/TerryZ/v-dropdown/compare/v3.0.0...v3.1.0) (2025-02-20) 70 | 71 | ### 新特性 72 | 73 | - 新增 `DropdownContent` 组件,作为下拉内容的容器,提供更好的结构化和样式控制 74 | - 新增 `DropdownTrigger` 组件,作为可选的内置的触发器组件 75 | - `fullWidth` prop 更名为 `block` 76 | - `animated` prop 从 `Dropdown` 组件中移除,改为 `DropdownContent` 组件的 prop 77 | - 移除 `width` 与 `customContainerClass` prop,可在 `DropdownContent` 组件直接通过 style 或 class 进行样式定制 78 | - 移除 `customTriggerClass` prop,可在 `DropdownTrigger` 组件直接通过 style 或 class 进行样式定制 79 | - `Dropdown` 新增 `gap` prop,用于设置触发器与下拉内容之间的间距 80 | - `Dropdown` 新增 `open`、`opened`、`close` 与 `closed` 事件,用于响应下拉栏的打开与关闭状态 81 | - `Dropdown` 的 API 中移除 `adjust` 函数 82 | - 下拉内容尺寸发生变化时,自动调整下拉位置 83 | - 下拉栏与触发器提供 `rounded` prop 设置圆角弧度 84 | - 下拉栏提供 `z-index` prop 设置 z-index 层级 85 | - 提供 `useDropdown` 工具函数,用于获得 dropdown 组件的各种状态与方法 86 | 87 | ## [3.0.0](https://github.com/TerryZ/v-dropdown/compare/v3.0.0-beta.2...v3.0.0) (2023-09-16) 88 | 89 | ### 问题修复 90 | 91 | - 更新 `.d.ts` 文档内容 92 | 93 | ## [3.0.0-beta.2](https://github.com/TerryZ/v-dropdown/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2023-09-15) 94 | 95 | ### 新特性 96 | 97 | - 新增 `customTriggerClass` prop,用于自定义触发对象样式 98 | - 新增 `customContainerClass` prop,用于自定义下拉容器样式 99 | - 更新 `.d.ts` 文档内容 100 | 101 | ### 问题修复 102 | 103 | - 在 **FireFox** 下初次打开下拉容器出现位置漂移现象 104 | - 插件辅助提示失效 105 | 106 | ## [3.0.0-beta.1](https://github.com/TerryZ/v-dropdown/compare/v2.1.1...v3.0.0-beta.1) (2023-02-08) 107 | 108 | ### 新特性 109 | 110 | - 使用 vue3 **composition api** 重构 `v-dropdown` 111 | - 工具链从 `webpack` 更换为 `vite` 112 | - 单元测试库从 `mocha` 更换为 `vitest` 113 | - `show` 事件更名为 `visible-change` 114 | - 为触发对象增加作用域插槽功能支持,输出 `visible` 与 `disabled` 数据状态 115 | - 添加 `trigger` prop,用于指定 dropdown 的触发模式 116 | - 移除 `right-click` prop,该功能设置转为使用设置 `trigger` prop 值为 `contextmenu` 117 | - 移除 `embed` prop 118 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Please refer to [CHANGELOG-CN](CHANGELOG-CN.md) for Chinese changelog 4 | 5 | ## [3.5.1](https://github.com/TerryZ/v-dropdown/compare/v3.5.0...v3.5.1) (2025-09-24) 6 | 7 | ### Bug Fixes 8 | 9 | - `align` alignment setting is invalid 10 | 11 | ## [3.5.0](https://github.com/TerryZ/v-dropdown/compare/v3.4.0...v3.5.0) (2025-09-23) 12 | 13 | ### Features 14 | 15 | - Removed the `animated` prop from `DropdownContent` 16 | - Added the `animated` prop to the `Dropdown` component, which sets whether to use a transition animation when the dropdown is opened 17 | - Refactored some core implementations to optimize performance 18 | - Added edge overflow handling when the dropdown is opened 19 | - When no `trigger` slot content is specified, the built-in `DropdownTrigger` component will be used by default 20 | 21 | ## [3.4.0](https://github.com/TerryZ/v-dropdown/compare/v3.3.0...v3.4.0) (2025-03-14) 22 | 23 | ### Features 24 | 25 | - `Dropdown` component add `appendTo` prop, used to specify after which element the dropdown container is inserted 26 | 27 | ## [3.3.0](https://github.com/TerryZ/v-dropdown/compare/v3.2.1...v3.3.0) (2025-03-08) 28 | 29 | ### Features 30 | 31 | - `DropdownContent` component remove `animationName` prop 32 | 33 | ### Bug Fixes 34 | 35 | - Fixed the issue of opening/closing the dropdown content transition animation failing 36 | 37 | 38 | ## [3.2.1](https://github.com/TerryZ/v-dropdown/compare/v3.2.0...v3.2.1) (2025-03-05) 39 | 40 | ### Bug Fixes 41 | 42 | - Fixed the issue where the resize observer's target element does not exist after code hot updates during development 43 | 44 | ## [3.2.0](https://github.com/TerryZ/v-dropdown/compare/v3.1.3...v3.2.0) (2025-03-03) 45 | 46 | ### Features 47 | 48 | - Added `adjust` function to slot data and `useDropdown` utility function 49 | - Automatically adjust the dropdown content position when the trigger position and height change 50 | 51 | ## [3.1.3](https://github.com/TerryZ/v-dropdown/compare/v3.1.2...v3.1.3) (2025-02-24) 52 | 53 | ### Bug Fixes 54 | 55 | - Unit test `ResizeObserver is not defined` fixed 56 | 57 | ## [3.1.2](https://github.com/TerryZ/v-dropdown/compare/v3.1.1...v3.1.2) (2025-02-24) 58 | 59 | ### Features 60 | 61 | - Optimize `ResizeObserver` compatibility and SSR compilation problem handling 62 | 63 | ## [3.1.1](https://github.com/TerryZ/v-dropdown/compare/v3.1.0...v3.1.1) (2025-02-23) 64 | 65 | ### Features 66 | 67 | - Added maximum width limit for drop-down bar container 68 | - `ResizeObserver` compatibility processing 69 | 70 | ## [3.1.0](https://github.com/TerryZ/v-dropdown/compare/v3.0.0...v3.1.0) (2025-02-20) 71 | 72 | ### Features 73 | 74 | - Added the `DropdownContent` component as a container for dropdown content, enabling better structural organization and style control 75 | - Added the `DropdownTrigger` component as an optional built-in trigger component 76 | - The `fullWidth` prop has been renamed to `block` 77 | - The `animated` prop has been removed from the `Dropdown` component and is now a prop of the `DropdownContent` component 78 | - Removed the `width` and `customContainerClass` props; styles can now be directly customized via `style` or `class` on the `DropdownContent` component 79 | - Removed the `customTriggerClass` prop; styles can now be directly customized via `style` or `class` on the `DropdownTrigger` component 80 | - Added a `gap` prop to the `Dropdown` component to set spacing between the trigger and dropdown content 81 | - Added `open`, `opened`, `close`, and `closed` events to the `Dropdown` component to respond to dropdown state changes 82 | - The `adjust` function has been removed from the `Dropdown` API 83 | - Dropdown position now automatically adjusts when content dimensions change 84 | - Added a `rounded` prop to both the dropdown and trigger for configuring border radius 85 | - Added a `z-index` prop to the dropdown for controlling layer hierarchy 86 | - Provides `useDropdown` utility function to obtain various states and methods of the dropdown component 87 | 88 | ## [3.0.0](https://github.com/TerryZ/v-dropdown/compare/v3.0.0-beta.2...v3.0.0) (2023-09-16) 89 | 90 | ### Bug Fixes 91 | 92 | - Update `.d.ts` document 93 | 94 | ## [3.0.0-beta.2](https://github.com/TerryZ/v-dropdown/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2023-09-15) 95 | 96 | ### Features 97 | 98 | - Add `customTriggerClass` prop, used to add custom class to trigger container 99 | - Add `customContainerClass` prop, used to add custom class to dropdown container 100 | - Update `.d.ts` document 101 | 102 | ### Bug Fixes 103 | 104 | - Position drift when first opening a dropdown container in **FireFox** 105 | - Component code assist not working 106 | 107 | ## [3.0.0-beta.1](https://github.com/TerryZ/v-dropdown/compare/v2.1.1...v3.0.0-beta.1) (2023-02-08) 108 | 109 | ### Features 110 | 111 | - refactor `v-dropdown` with vue3 composition api 112 | - change module bundler library from `webpack` to `vite` 113 | - change unit test library from `mocha` to `vitest` 114 | - `show` event rename to `visible-change` 115 | - add scoped slot feature to trigger slot, response `visible` and `disabled` states 116 | - add `trigger` prop, use to set dropdown trigger mode 117 | - remove `right-click` prop and change to `trigger` set to `contextmenu` instead 118 | - remove `embed` prop 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Terry Zeng 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 | # v-dropdown · [![test](https://github.com/TerryZ/v-dropdown/actions/workflows/npm-publish.yml/badge.svg?branch=master)](https://github.com/TerryZ/v-dropdown/actions/workflows/npm-publish.yml) [![codecov](https://codecov.io/gh/TerryZ/v-dropdown/branch/master/graph/badge.svg?token=veg52RGaZg)](https://codecov.io/gh/TerryZ/v-dropdown) [![npm version](https://img.shields.io/npm/v/v-dropdown.svg)](https://www.npmjs.com/package/v-dropdown) [![npm downloads](https://img.shields.io/npm/dy/v-dropdown.svg)](https://www.npmjs.com/package/v-dropdown) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 2 | 3 | A dropdown container for Vue3 4 | 5 | If you are using vue `2.x` version, please use [v-dropdown 2.x](https://github.com/TerryZ/v-dropdown/tree/dev-vue-2) version instead 6 | 7 | ## Features 8 | 9 | - Multiple drop-down bar triggering methods 10 | - Built-in trigger button component for quick use 11 | - When the position and size of the trigger and the drop-down bar content change, the drop-down bar content position is automatically adjusted to align - with the trigger 12 | - Provide `Dropdown` status and tool functions for each slot 13 | - Provide `useDropdown` utility functions 14 | - Flexible style customization method 15 | 16 | ## Documentation and Examples 17 | 18 | Documentation and examples please visit below sites 19 | 20 | - [github-pages](https://terryz.github.io/docs-vue3/dropdown/) 21 | 22 | ## My repositories using `v-dropdown` 23 | 24 | - [v-selectpage](https://github.com/TerryZ/v-selectpage) 25 | - [v-selectmenu](https://github.com/TerryZ/v-selectmenu) 26 | - [v-region](https://github.com/TerryZ/v-region) 27 | - [v-suggest](https://github.com/TerryZ/v-suggest) 28 | 29 | ## Installation 30 | 31 | [![https://nodei.co/npm/v-dropdown.png?downloads=true&downloadRank=true&stars=true](https://nodei.co/npm/v-dropdown.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/v-dropdown) 32 | 33 | ```sh 34 | # npm 35 | npm i v-dropdown 36 | # yarn 37 | yarn add v-dropdown 38 | # pnpm 39 | pnpm add v-dropdown 40 | ``` 41 | 42 | ## Usage 43 | 44 | ### Quick dropdown 45 | 46 | ```vue 47 | 54 | 55 | 58 | ``` 59 | 60 | ### Custom trigger content 61 | 62 | ```vue 63 | 74 | 75 | 78 | ``` 79 | 80 | ## License 81 | 82 | [![license](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://mit-license.org/) 83 | -------------------------------------------------------------------------------- /examples/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /examples/CustomContent.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 36 | -------------------------------------------------------------------------------- /examples/ExamplesIndex.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 596 | -------------------------------------------------------------------------------- /examples/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import 'bootstrap/dist/css/bootstrap.min.css' 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | v-dropdown examples 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "ES6", 8 | "module": "commonjs", 9 | "allowSyntheticDefaultImports": true, 10 | "jsx": "preserve" 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules"] 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-dropdown", 3 | "description": "A dropdown layer container component for vue3", 4 | "version": "3.5.1", 5 | "author": "TerryZ ", 6 | "type": "module", 7 | "files": [ 8 | "./dist", 9 | "./types" 10 | ], 11 | "main": "./dist/v-dropdown.umd.cjs", 12 | "module": "./dist/v-dropdown.js", 13 | "exports": { 14 | ".": { 15 | "import": { 16 | "types": "./types/index.d.ts", 17 | "default": "./dist/v-dropdown.js" 18 | }, 19 | "require": "./dist/v-dropdown.umd.cjs" 20 | } 21 | }, 22 | "typings": "./types/index.d.ts", 23 | "license": "MIT", 24 | "packageManager": "pnpm@10.15.0", 25 | "scripts": { 26 | "dev": "vite", 27 | "build": "vite build", 28 | "preview": "vite preview --port 4173", 29 | "test:unit": "vitest", 30 | "coverage": "vitest run --coverage", 31 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/TerryZ/v-dropdown.git" 36 | }, 37 | "keywords": [ 38 | "javascript", 39 | "vue", 40 | "web", 41 | "front-end", 42 | "dropdown", 43 | "popup", 44 | "container" 45 | ], 46 | "peerDependencies": { 47 | "vue": "^3.5.0" 48 | }, 49 | "dependencies": { 50 | "vue": "^3.5.21" 51 | }, 52 | "devDependencies": { 53 | "@rushstack/eslint-patch": "^1.10.5", 54 | "@vitejs/plugin-vue": "^6.0.1", 55 | "@vitejs/plugin-vue-jsx": "^5.1.1", 56 | "@vitest/coverage-v8": "^3.2.4", 57 | "@vue/eslint-config-standard": "^8.0.1", 58 | "@vue/test-utils": "^2.4.6", 59 | "autoprefixer": "^10.4.21", 60 | "bootstrap": "^5.3.8", 61 | "eslint": "^8.49.0", 62 | "eslint-plugin-vue": "^9.32.0", 63 | "jsdom": "^26.1.0", 64 | "postcss": "^8.5.6", 65 | "sass": "^1.92.1", 66 | "typescript": "^5.9.2", 67 | "vite": "^7.1.4", 68 | "vite-plugin-css-injected-by-js": "^3.5.2", 69 | "vitest": "^3.2.4" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Dropdown.jsx: -------------------------------------------------------------------------------- 1 | import './styles/dropdown.sass' 2 | 3 | import { ref, defineComponent } from 'vue' 4 | import { getTriggerClasses } from './helper' 5 | import { useDropdownCore } from './core' 6 | import { TRIGGER_CLICK, APPEND_TO_BODY } from './constants' 7 | 8 | import DropdownTrigger from './DropdownTrigger' 9 | import DropdownContentContainer from './DropdownContentContainer' 10 | 11 | export default defineComponent({ 12 | name: 'VDropdown', 13 | props: { 14 | disabled: { type: Boolean, default: false }, 15 | /** Content show up alignment direction */ 16 | align: { type: String, default: 'left' }, 17 | /** Toggle display / close dropdown content */ 18 | toggle: { type: Boolean, default: true }, 19 | /** Manual control the display and hiding of dropdown */ 20 | manual: { type: Boolean, default: false }, 21 | /** 22 | * Trigger container display mode 23 | * - false: inline-flex 24 | * - true: flex 25 | */ 26 | block: { type: Boolean, default: false }, 27 | /** 28 | * Dropdown trigger method 29 | * - `click` default 30 | * - `hover` 31 | * - `contextmenu` 32 | */ 33 | trigger: { type: String, default: TRIGGER_CLICK }, 34 | animated: { type: Boolean, default: true }, 35 | /** The distance(px) between the trigger and the content */ 36 | gap: { type: Number, default: 5 }, 37 | /** Dropdown content append target */ 38 | appendTo: { type: [String, Object], default: APPEND_TO_BODY } 39 | }, 40 | emits: ['visible-change', 'open', 'close', 'opened', 'closed'], 41 | setup (props, context) { 42 | const { slots } = context 43 | 44 | const triggerRef = ref() 45 | const contentRef = ref() 46 | 47 | const { 48 | slotData, 49 | visible, 50 | getContentClass, 51 | contentStyles 52 | } = useDropdownCore(triggerRef, contentRef, props, context) 53 | 54 | const Trigger = () => slots.trigger ? slots.trigger(slotData) : 55 | const Content = () => { 56 | return ( 57 | 58 | {() => ( 59 |
{slots?.default?.(slotData)}
65 | )} 66 |
67 | ) 68 | } 69 | 70 | return () => ( 71 |
72 | 73 | 74 |
75 | ) 76 | } 77 | }) 78 | -------------------------------------------------------------------------------- /src/DropdownContent.jsx: -------------------------------------------------------------------------------- 1 | import { inject, defineComponent } from 'vue' 2 | import { keyInternal, ROUNDED_SMALL, Z_INDEX } from './constants' 3 | import { getContentRoundedClass } from './helper' 4 | 5 | export default defineComponent({ 6 | name: 'DropdownContent', 7 | // inheritAttrs: false, 8 | props: { 9 | border: { type: Boolean, default: true }, 10 | rounded: { type: String, default: ROUNDED_SMALL }, 11 | zIndex: { type: Number, default: Z_INDEX } 12 | }, 13 | setup (props, { slots, attrs }) { 14 | const { setContentClassGetter, contentStyles } = inject(keyInternal, {}) 15 | 16 | setContentClassGetter?.(() => { 17 | const classes = ['dd-content', getContentRoundedClass(props.rounded)] 18 | if (!props.border) classes.push('dd-no-border') 19 | return classes 20 | }) 21 | 22 | return () => { 23 | if (contentStyles) { 24 | contentStyles.value['z-index'] = props.zIndex 25 | } 26 | return
{slots?.default?.()}
27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /src/DropdownContentContainer.jsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, Teleport, Transition, inject } from 'vue' 2 | 3 | import { keyContainer } from './constants' 4 | 5 | export default defineComponent({ 6 | name: 'DropdownContentContainer', 7 | setup (props, { slots }) { 8 | const { 9 | appendTo, 10 | defer, 11 | transitionName, 12 | onDropdownOpen, 13 | onDropdownOpened, 14 | onDropdownClose, 15 | onDropdownClosed 16 | } = inject(keyContainer, {}) 17 | 18 | const handleOnEnter = (el, done) => { 19 | onDropdownOpen() 20 | setTimeout(done, 150) 21 | } 22 | const handleOnAfterEnter = el => { 23 | onDropdownOpened() 24 | } 25 | const handleOnLeave = (el, done) => { 26 | onDropdownClose() 27 | setTimeout(done, 75) 28 | } 29 | const handleOnAfterLeave = el => { 30 | onDropdownClosed() 31 | } 32 | 33 | return () => ( 34 | 35 | {() => slots?.default?.()} 42 | 43 | ) 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /src/DropdownTrigger.jsx: -------------------------------------------------------------------------------- 1 | import './styles/trigger.sass' 2 | 3 | import { computed, defineComponent } from 'vue' 4 | 5 | import { ROUNDED_MEDIUM, ROUNDED_CIRCLE } from './constants' 6 | import { getRoundedClass } from './helper' 7 | import { useDropdown } from './use' 8 | 9 | export default defineComponent({ 10 | name: 'DropdownTrigger', 11 | props: { 12 | rounded: { type: String, default: ROUNDED_MEDIUM } 13 | }, 14 | setup (props, { slots }) { 15 | const dropdown = useDropdown() 16 | 17 | const buttonClasses = computed(() => (['dd-default-trigger', 18 | { 'dd-opened': dropdown?.visible?.value }, 19 | getRoundedClass(props.rounded) 20 | ])) 21 | const containerClasses = computed(() => ({ 22 | 'dd-trigger-container': true, 23 | 'dd-disabled': dropdown?.disabled?.value 24 | })) 25 | 26 | const ButtonText = () => slots.default ? slots.default() : 'Open' 27 | const ButtonIcon = () => { 28 | if (props.rounded === ROUNDED_CIRCLE) return null 29 | return ( 30 | slots.append ? slots.append() : 31 | ) 32 | } 33 | const TriggerButton = () => ( 34 | 41 | ) 42 | 43 | return () => ( 44 |
45 | 46 |
47 | ) 48 | } 49 | }) 50 | -------------------------------------------------------------------------------- /src/__tests__/components/DropdownCore.jsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, DropdownContent, DropdownTrigger } from '../../' 2 | 3 | import CustomContent from '../../../examples/CustomContent.vue' 4 | 5 | export function DropdownBaseContent (props) { 6 | return ( 7 | 8 |
0123456789012345678901234567890123456789
9 |
0123456789012345678901234567890123456789
10 |
0123456789012345678901234567890123456789
11 |
0123456789012345678901234567890123456789
12 |
0123456789012345678901234567890123456789
13 |
0123456789012345678901234567890123456789
14 |
0123456789012345678901234567890123456789
15 |
0123456789012345678901234567890123456789
16 |
0123456789012345678901234567890123456789
17 |
0123456789012345678901234567890123456789
18 |
0123456789012345678901234567890123456789
19 |
0123456789012345678901234567890123456789
20 |
21 | ) 22 | } 23 | export function DropdownBaseTrigger (props) { 24 | return 25 | } 26 | export function PropsToDropdownContent (props) { 27 | const slots = { 28 | default: () => abc, 29 | trigger: () => 30 | } 31 | return 32 | } 33 | // 暂不可用,参数无法传递入 trigger,也无法触发 trigger 的重新渲染 34 | export function PropsToDropdownTrigger (props) { 35 | return ( 36 | {{ 37 | default: () => , 38 | trigger: () => 39 | }} 40 | ) 41 | } 42 | export function DropdownWithSlotData (props) { 43 | const slots = { 44 | default: () => ( 45 | 46 | 47 | 48 | ), 49 | trigger: data => ( 50 | 51 |
52 | visible: { String(data.visible.value) } 53 |
54 |
55 | disabled: { String(data.disabled.value) } 56 |
57 |
58 | ) 59 | } 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /src/__tests__/components/PropsToDropdownContent.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 | -------------------------------------------------------------------------------- /src/__tests__/components/PropsToDropdownTrigger.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 27 | -------------------------------------------------------------------------------- /src/__tests__/core.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, test } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { h } from 'vue' 4 | 5 | import { Dropdown } from '@/' 6 | // import { } from '../../src/helper' 7 | import { getElementRect } from '../../src/util' 8 | 9 | describe('v-dropdown core', () => { 10 | const wrapper = mount(Dropdown, { 11 | slots: { 12 | default: h('div', 'contents'), 13 | trigger: h('button', { type: 'button' }, 'trigger') 14 | } 15 | }) 16 | 17 | it('the dropdown container should be closed by default', () => { 18 | // 默认 visible 状态必须为 false 19 | expect(wrapper.vm.visible).equal(false) 20 | }) 21 | // it('call display method, set visible to true, the dropdown container should be displayed', async () => { 22 | // wrapper.vm.open() 23 | // await nextTick() 24 | 25 | // expect(wrapper.vm.visible).equal(true) 26 | // // visible 状态为 true 时,下拉栏必须为打开状态 27 | // expect(wrapper.vm.container.getAttribute('style')).not.include('display: none') 28 | // }) 29 | // it('call close method, set visible to false, the dropdown container should be closed', async () => { 30 | // wrapper.vm.close() 31 | // await nextTick() 32 | 33 | // expect(wrapper.vm.visible).equal(false) 34 | // // visible 状态为 false 时,下拉栏必须为关闭状态 35 | // expect(wrapper.vm.container.getAttribute('style')).include('display: none') 36 | // }) 37 | }) 38 | 39 | describe('utils', () => { 40 | test('getElementRect 提供的 dom 元素为空时,应均响应 0 值', () => { 41 | const rect = getElementRect(null) 42 | expect(rect).toEqual({ 43 | left: 0, 44 | top: 0, 45 | width: 0, 46 | height: 0 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/__tests__/events.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { h } from 'vue' 4 | 5 | import { Dropdown } from '@/' 6 | import { DropdownBaseContent } from './components/DropdownCore' 7 | 8 | describe('v-dropdown 事件', () => { 9 | const fnOpen = vi.fn() 10 | const fnOpened = vi.fn() 11 | const fnClose = vi.fn() 12 | const fnClosed = vi.fn() 13 | const wrapper = mount(Dropdown, { 14 | slots: { 15 | default: DropdownBaseContent, 16 | trigger: () => h('button', { type: 'button' }, 'trigger') 17 | }, 18 | props: { 19 | onOpen: fnOpen, 20 | onOpened: fnOpened, 21 | onClose: fnClose, 22 | onClosed: fnClosed 23 | }, 24 | global: { 25 | stubs: { 26 | transition: false 27 | } 28 | } 29 | }) 30 | 31 | test('打开 dropdown, `visible-change` 事件应响应 true 值', async () => { 32 | vi.useFakeTimers() 33 | await wrapper.trigger('click') 34 | expect(wrapper.emitted()['visible-change'][0]).toEqual([true]) 35 | }) 36 | test('响应 `open` 事件', () => { 37 | expect(fnOpen).toHaveBeenCalled() 38 | }) 39 | test('响应 `opened` 事件', () => { 40 | vi.runAllTimers() 41 | expect(fnOpened).toHaveBeenCalled() 42 | }) 43 | test('关闭 dropdown, `visible-change` 事件应响应 false 值', async () => { 44 | await wrapper.trigger('click') 45 | // console.log(wrapper.emitted()) 46 | expect(wrapper.emitted()['visible-change'][1]).toEqual([false]) 47 | }) 48 | test('响应 `close` 事件', () => { 49 | expect(fnClose).toHaveBeenCalled() 50 | }) 51 | test('响应 `closed` 事件', () => { 52 | vi.runAllTimers() 53 | expect(fnClosed).toHaveBeenCalled() 54 | vi.useRealTimers() 55 | }) 56 | }) 57 | 58 | // describe('v-dropdown 监听响应处理', () => { 59 | // test('afsadf', async () => { 60 | // const wrapper = mount(PropsToDropdownContent) 61 | // wrapper.vm.$nextTick() 62 | // // await nextTick() 63 | // // const mockCallback = vi.fn() 64 | 65 | // // const ro = new ResizeObserver(mockCallback) 66 | // // const ro = (globalThis.ResizeObserver).mock.instances[0] 67 | // const observerInstance = vi.mocked(window.ResizeObserver).mock.instances[0] 68 | // console.log(observerInstance) 69 | // // 模拟元素 resize 70 | // // ro.__trigger([{ target: document.createElement('div') }], ro) 71 | // // instance.__trigger([ 72 | // // { 73 | // // target: wrapper.find('div').element, 74 | // // contentRect: { width: 100, height: 50 } 75 | // // } 76 | // // ]) 77 | // // globalThis.ResizeObserver 78 | 79 | // // expect(mockCallback).toHaveBeenCalled() 80 | // // expect(instance.observe).toHaveBeenCalled() 81 | // // expect(instance.disconnect).not.toHaveBeenCalled() 82 | // }) 83 | // }) 84 | -------------------------------------------------------------------------------- /src/__tests__/methods.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { h, nextTick } from 'vue' 4 | 5 | import { Dropdown } from '@/' 6 | 7 | describe('v-dropdown methods', () => { 8 | const wrapper = mount(Dropdown, { 9 | slots: { 10 | default: h('div', 'contents'), 11 | trigger: h('button', { type: 'button' }, 'trigger') 12 | } 13 | }) 14 | 15 | it('call `display` method, the dropdown container should be display', async () => { 16 | wrapper.vm.open() 17 | await nextTick() 18 | expect(wrapper.vm.visible).equal(true) 19 | }) 20 | it('call `close` method, the dropdown container should be close', async () => { 21 | wrapper.vm.close() 22 | await nextTick() 23 | expect(wrapper.vm.visible).equal(false) 24 | }) 25 | it('call `toggleVisible` method, the dropdown container should be display', async () => { 26 | wrapper.vm.toggleVisible() 27 | await nextTick() 28 | expect(wrapper.vm.visible).equal(true) 29 | }) 30 | it('call `toggleVisible` method again, the dropdown container should be close', async () => { 31 | wrapper.vm.toggleVisible() 32 | await nextTick() 33 | expect(wrapper.vm.visible).equal(false) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/__tests__/props-content.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { nextTick } from 'vue' 4 | 5 | import { PropsToDropdownContent } from './components/DropdownCore' 6 | // import PropsToDropdownContent from './components/PropsToDropdownContent.vue' 7 | 8 | describe('dropdown content props', async () => { 9 | const wrapper = mount(PropsToDropdownContent, { 10 | props: { 11 | border: false 12 | }, 13 | global: { 14 | stubs: { 15 | transition: false 16 | } 17 | } 18 | }) 19 | 20 | await nextTick() 21 | 22 | // const content = wrapper.findComponent('.dd-content') 23 | const content = document.body.querySelector('.dd-content') 24 | 25 | test('默认的圆角尺寸应为 `small`', () => { 26 | expect(content.classList.contains('dd-content-rounded--small')).toBeTruthy() 27 | }) 28 | test('`border` prop 设置为 false, 下拉栏容器应包含 `dd-no-border` 样式名', () => { 29 | expect(content.classList.contains('dd-no-border')).toBeTruthy() 30 | }) 31 | test('`z-index` prop 设置为 1000, 下拉栏容器的 `z-index` 样式值应为 1000', async () => { 32 | await wrapper.setProps({ zIndex: 1000 }) 33 | // 手动触发更新 34 | await wrapper.trigger('click') 35 | 36 | expect(content.style.zIndex).toBe('1000') 37 | }) 38 | // TODO: ResizeObserver 与 IntersectionObserver 功能目前无法测试,关注后续是否有解决方案 39 | // test('修改下拉栏宽度,应响应重定位', async () => { 40 | // console.log(content.html()) 41 | // content.element.style.width = '500px' 42 | // await nextTick() 43 | // console.log(content.html()) 44 | // console.log(document.body.outerHTML) 45 | // expect() 46 | // }) 47 | test('`rounded` prop 设置为 `large`, 容器的圆角尺寸应为 `large`', async () => { 48 | await wrapper.setProps({ rounded: 'large' }) 49 | await wrapper.trigger('click') 50 | expect(content.classList.contains('dd-content-rounded--large')).toBeTruthy() 51 | }) 52 | test('`rounded` prop 设置为 `medium11`, 容器的圆角尺寸应恢复为 `small`', async () => { 53 | await wrapper.setProps({ rounded: 'medium11' }) 54 | await wrapper.trigger('click') 55 | expect(content.classList.contains('dd-content-rounded--small')).toBeTruthy() 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/__tests__/props-trigger.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { h } from 'vue' 4 | 5 | import PropsToDropdownTrigger from './components/PropsToDropdownTrigger.vue' 6 | 7 | describe('built-in dropdown trigger props', () => { 8 | const wrapper = mount(PropsToDropdownTrigger, { 9 | slots: { 10 | default: h('div', 'Custom content'), 11 | append: h('span', 'Custom icon') 12 | } 13 | }) 14 | 15 | test('自定义按钮内容文本应为 `Custom content`', () => { 16 | expect(wrapper.find('button').find('div').text()).toBe('Custom content') 17 | }) 18 | test('自定义按钮图标内容应为 `Custom icon`', () => { 19 | expect(wrapper.find('button').find('span').text()).toBe('Custom icon') 20 | }) 21 | test('默认的圆角尺寸应为 `medium`', () => { 22 | expect(wrapper.find('button').classes()).toContain('dd-rounded--medium') 23 | }) 24 | test('`rounded` prop 设置为 `pill`, 容器的圆角尺寸应为 `pill`', async () => { 25 | await wrapper.setProps({ rounded: 'pill' }) 26 | // console.log(wrapper.find('button').classes()) 27 | expect(wrapper.find('button').classes()).toContain('dd-rounded--pill') 28 | }) 29 | test('`rounded` prop 设置为 `circle`, 容器的圆角尺寸应为 `circle`', async () => { 30 | await wrapper.setProps({ rounded: 'circle' }) 31 | expect(wrapper.find('button').classes()).toContain('dd-rounded--circle') 32 | }) 33 | test('`rounded` prop 设置为 `large11`, 容器的圆角尺寸应恢复为 `medium`', async () => { 34 | await wrapper.setProps({ rounded: 'large11' }) 35 | expect(wrapper.find('button').classes()).toContain('dd-rounded--medium') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/__tests__/props.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { h, nextTick } from 'vue' 4 | 5 | import { Dropdown, DropdownContent } from '../' 6 | import { 7 | DropdownBaseContent 8 | } from './components/DropdownCore' 9 | // import { getCssStyle } from './util' 10 | 11 | describe('v-dropdown props', () => { 12 | describe('dropdown props', () => { 13 | const wrapper = mount(Dropdown, { 14 | props: { 15 | width: 500, 16 | customTriggerClass: 'custom-trigger', 17 | customContainerClass: 'custom-container' 18 | }, 19 | slots: { 20 | default: () => DropdownBaseContent, 21 | trigger: h('button', { type: 'button' }, 'trigger') 22 | } 23 | }) 24 | 25 | // it('`customTriggerClass` set `custom-trigger` value, the dropdown container should have `custom-trigger` class', () => { 26 | // expect(wrapper.classes('custom-trigger')).toBeTruthy() 27 | // }) 28 | // it('`customContainerClass` set `custom-container` value, the trigger container should have `custom-container` class', () => { 29 | // expect(wrapper.vm.container.classList.contains('custom-container')).toBeTruthy() 30 | // }) 31 | it('`block` prop 设置为 true, 触发元素容器应包含 `dd-trigger--block` 样式名', async () => { 32 | await wrapper.setProps({ block: true }) 33 | expect(wrapper.classes()).toContain('dd-trigger--block') 34 | }) 35 | // it('`width` prop set 500, the dropdown container width should be `500px`', () => { 36 | // expect(getCssStyle(wrapper.vm.container).width).equal('500px') 37 | // }) 38 | it('`toggle` prop set to false, when dropdown container opened and click trigger element again, the dropdown container should not be closed', async () => { 39 | await wrapper.setProps({ toggle: false }) 40 | await wrapper.trigger('click') 41 | // 首次点击,打开下拉栏 42 | expect(wrapper.vm.visible).equal(true) 43 | await wrapper.trigger('click') 44 | // 再次点击,下拉栏不会收起,依然处于打开状态 45 | expect(wrapper.vm.visible).equal(true) 46 | }) 47 | it('`manual` prop set to true, the dropdown container should only can open by display method', async () => { 48 | await wrapper.setProps({ toggle: true, manual: true }) 49 | 50 | // 关闭窗口重置状态 51 | wrapper.vm.close() 52 | await nextTick() 53 | 54 | await wrapper.trigger('click') 55 | expect(wrapper.vm.visible).equal(false) 56 | 57 | wrapper.vm.open() 58 | await nextTick() 59 | expect(wrapper.vm.visible).equal(true) 60 | }) 61 | it('`disabled` prop set to true, clicking the trigger element, the dropdown container should not be display', async () => { 62 | // 关闭窗口重置状态 63 | wrapper.vm.close() 64 | await nextTick() 65 | 66 | await wrapper.setProps({ disabled: true }) 67 | await wrapper.trigger('click') 68 | expect(wrapper.vm.visible).equal(false) 69 | 70 | wrapper.vm.open() 71 | await nextTick() 72 | expect(wrapper.vm.visible).equal(false) 73 | }) 74 | }) 75 | 76 | describe('trigger mode', () => { 77 | describe('trigger by click', () => { 78 | const wrapper = mount(Dropdown, { 79 | slots: { 80 | default: () => h('div', 'contents'), 81 | trigger: h('button', { type: 'button' }, 'trigger') 82 | } 83 | }) 84 | it('click trigger to open dropdown container', async () => { 85 | await wrapper.trigger('click') 86 | expect(wrapper.vm.visible).equal(true) 87 | }) 88 | it('click trigger again to close dropdown container', async () => { 89 | await wrapper.trigger('click') 90 | expect(wrapper.vm.visible).equal(false) 91 | }) 92 | it('click outside of trigger container and dropdown container to close dropdown container', async () => { 93 | await wrapper.trigger('click') 94 | expect(wrapper.vm.visible).equal(true) 95 | 96 | // 组件外区域点击,关闭下拉栏 97 | document.body.dispatchEvent(new Event('mousedown')) 98 | await nextTick() 99 | expect(wrapper.vm.visible).equal(false) 100 | }) 101 | }) 102 | describe('trigger by hover', () => { 103 | const wrapper = mount(Dropdown, { 104 | props: { 105 | trigger: 'hover' 106 | }, 107 | slots: { 108 | default: () => h('div', 'contents'), 109 | trigger: h('button', { type: 'button' }, 'trigger') 110 | } 111 | }) 112 | it('mouseenter / mouseleave trigger container to display / close dropdown container', async () => { 113 | vi.useFakeTimers() 114 | 115 | expect(wrapper.vm.visible).equal(false) 116 | 117 | await wrapper.trigger('mouseenter') 118 | vi.runAllTimers() 119 | expect(wrapper.vm.visible).equal(true) 120 | await wrapper.trigger('mouseleave') 121 | vi.runAllTimers() 122 | expect(wrapper.vm.visible).equal(false) 123 | 124 | vi.useRealTimers() 125 | }) 126 | }) 127 | describe('trigger by contextmenu', () => { 128 | const wrapper = mount(Dropdown, { 129 | props: { 130 | trigger: 'contextmenu' 131 | }, 132 | slots: { 133 | default: () => h('div', 'contents'), 134 | trigger: h('button', { type: 'button' }, 'trigger') 135 | } 136 | }) 137 | it('mouse right click trigger container area, the dropdown container should be displayed', async () => { 138 | await wrapper.trigger('click') 139 | // 鼠标左健点击,不作响应 140 | expect(wrapper.vm.visible).equal(false) 141 | 142 | await wrapper.trigger('contextmenu') 143 | expect(wrapper.vm.visible).equal(true) 144 | }) 145 | }) 146 | }) 147 | 148 | describe('dropdown content append to target element', () => { 149 | const el = document.createElement('div') 150 | el.id = 'container' 151 | document.body.appendChild(el) 152 | 153 | const wrapper = mount(Dropdown, { 154 | props: { 155 | appendTo: el 156 | }, 157 | slots: { 158 | default: h(DropdownContent, () => h('div', 'contents')), 159 | trigger: h('button', { type: 'button' }, 'trigger') 160 | } 161 | }) 162 | 163 | it('dropdown content 应添加到自定义元素中', async () => { 164 | await wrapper.trigger('click') 165 | expect(el.querySelectorAll('.dd-content')).toHaveLength(1) 166 | }) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/__tests__/setup.js: -------------------------------------------------------------------------------- 1 | // import ResizeObserver from 'resize-observer-polyfill' 2 | // global.ResizeObserver = ResizeObserver 3 | import { vi } from 'vitest' 4 | 5 | class MockResizeObserver { 6 | _callback 7 | 8 | constructor (callback) { 9 | this._callback = callback 10 | } 11 | 12 | observe = vi.fn() 13 | unobserve = vi.fn() 14 | disconnect = vi.fn() 15 | 16 | // 提供一个方法方便测试时手动触发 17 | __trigger (entries) { 18 | this._callback(entries, this) 19 | } 20 | } 21 | 22 | class IntersectionObserver { 23 | callback 24 | elements = [] 25 | 26 | constructor (callback) { 27 | this.callback = callback 28 | } 29 | 30 | observe = (element) => { 31 | this.elements.push(element) 32 | } 33 | 34 | unobserve = (element) => { 35 | this.elements = this.elements.filter(el => el !== element) 36 | } 37 | 38 | disconnect = () => { 39 | this.elements = [] 40 | } 41 | 42 | // 手动触发回调 43 | trigger = (entries) => { 44 | this.callback(entries, this) 45 | } 46 | } 47 | 48 | // 全局替换 49 | // vi.stubGlobal('ResizeObserver', ResizeObserver) 50 | // vi.stubGlobal('ResizeObserver', ResizeObserver) 51 | vi.stubGlobal('ResizeObserver', vi.fn().mockImplementation((callback) => { 52 | return new MockResizeObserver(callback) 53 | })) 54 | 55 | global.ResizeObserver = MockResizeObserver 56 | global.IntersectionObserver = IntersectionObserver 57 | -------------------------------------------------------------------------------- /src/__tests__/slot-data.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { nextTick } from 'vue' 4 | 5 | import { DropdownWithSlotData } from './components/DropdownCore' 6 | 7 | describe('dropdown slot data', async () => { 8 | // trigger 中的内容使用 scopedSlot 输出的数据 9 | // content 中使用 useDropdown 工具类获得的依赖注入数据 10 | const wrapper = mount(DropdownWithSlotData, { 11 | global: { 12 | stubs: { 13 | transition: false 14 | } 15 | } 16 | }) 17 | 18 | await nextTick() 19 | 20 | // const content = wrapper.findComponent('.dd-content') 21 | const content = document.body.querySelector('.dd-content') 22 | const button = wrapper.find('.dd-default-trigger') 23 | 24 | test('默认状态下,`visible` 值应为 `false`', () => { 25 | // console.log(wrapper.html()) 26 | expect(button.find('.trigger-data-visible').text()).toBe('visible: false') 27 | expect( 28 | content.querySelector('.content-data-visible').textContent.trim() 29 | ).toBe('visible: false') 30 | }) 31 | test('默认状态下,`disabled` 值应为 `false`', () => { 32 | // console.log(content.html()) 33 | expect(button.find('.trigger-data-disabled').text()).toBe('disabled: false') 34 | expect( 35 | content.querySelector('.content-data-disabled').textContent.trim() 36 | ).toBe('disabled: false') 37 | }) 38 | test('设置 `disabled` prop 为 `true`, `disabled` 值应为 `true`', async () => { 39 | await wrapper.setProps({ disabled: true }) 40 | expect(button.find('.trigger-data-disabled').text()).toBe('disabled: true') 41 | expect( 42 | content.querySelector('.content-data-disabled').textContent.trim() 43 | ).toBe('disabled: true') 44 | }) 45 | test('设置 `disabled` prop 为 `false`, `disabled` 值应恢复为 `false`', async () => { 46 | await wrapper.setProps({ disabled: false }) 47 | expect(button.find('.trigger-data-disabled').text()).toBe('disabled: false') 48 | expect( 49 | content.querySelector('.content-data-disabled').textContent.trim() 50 | ).toBe('disabled: false') 51 | }) 52 | test('点击 trigger 元素,下拉栏应为打开状态', async () => { 53 | await wrapper.trigger('click') 54 | expect(button.find('.trigger-data-visible').text()).toBe('visible: true') 55 | }) 56 | test('调用 useDropdown 获得的 close 函数,下拉栏应被关闭', async () => { 57 | content.querySelector('.content-data-close').click() 58 | await nextTick() 59 | expect(button.find('.trigger-data-visible').text()).toBe('visible: false') 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/__tests__/util.js: -------------------------------------------------------------------------------- 1 | export function getCssStyle (el) { 2 | return window.getComputedStyle(el, null) 3 | } 4 | 5 | export function getCssDisplay (el) { 6 | return getCssStyle(el).display 7 | } 8 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const TRIGGER_CLICK = 'click' 2 | export const TRIGGER_HOVER = 'hover' 3 | export const TRIGGER_CONTEXTMENU = 'contextmenu' 4 | 5 | export const HOVER_RESPONSE_TIME = 150 6 | 7 | export const Z_INDEX = 3000 8 | 9 | export const APPEND_TO_BODY = 'body' 10 | 11 | export const ROUNDED_SMALL = 'small' 12 | export const ROUNDED_MEDIUM = 'medium' 13 | export const ROUNDED_LARGE = 'large' 14 | export const ROUNDED_PILL = 'pill' 15 | export const ROUNDED_CIRCLE = 'circle' 16 | 17 | export const roundedList = [ 18 | ROUNDED_SMALL, 19 | ROUNDED_MEDIUM, 20 | ROUNDED_LARGE, 21 | ROUNDED_PILL, 22 | ROUNDED_CIRCLE 23 | ] 24 | export const contentRoundedList = [ROUNDED_SMALL, ROUNDED_MEDIUM, ROUNDED_LARGE] 25 | 26 | export const keyDropdown = Symbol('dropdown') 27 | export const keyInternal = Symbol('internal') 28 | export const keyContainer = Symbol('container') 29 | 30 | export const DIRECTION_LEFT = 'left' 31 | export const DIRECTION_CENTER = 'center' 32 | export const DIRECTION_RIGHT = 'right' 33 | export const DIRECTION_UP = 'up' 34 | export const DIRECTION_DOWN = 'down' 35 | -------------------------------------------------------------------------------- /src/core.js: -------------------------------------------------------------------------------- 1 | import { ref, watch, provide, computed, toRefs } from 'vue' 2 | 3 | import { 4 | useDebounce, 5 | useDropdownContentDirection, 6 | useEventListener, 7 | useResizeObserver, 8 | useIntersectionObserver 9 | } from './use' 10 | import { getTriggerState } from './helper' 11 | import { 12 | HOVER_RESPONSE_TIME, 13 | APPEND_TO_BODY, 14 | DIRECTION_RIGHT, DIRECTION_DOWN, 15 | keyContainer, keyDropdown, keyInternal 16 | } from './constants' 17 | 18 | export function useDropdownCore ( 19 | triggerRef, 20 | contentRef, 21 | props, 22 | context 23 | ) { 24 | const { emit, expose } = context 25 | const { disabled, manual, toggle } = toRefs(props) 26 | 27 | const visible = ref(false) 28 | const position = ref({ x: null, y: null }) 29 | const direction = ref({ vertical: DIRECTION_DOWN, horizontal: DIRECTION_RIGHT }) 30 | const getContentClass = ref() 31 | const contentStyles = ref({}) 32 | const transitionName = computed(() => { 33 | if (!props.animated) return '' 34 | return `drop-${direction.value.vertical}-${direction.value.horizontal}` 35 | }) 36 | 37 | const appendTo = props.appendTo || APPEND_TO_BODY 38 | const defer = props.appendTo !== APPEND_TO_BODY 39 | 40 | const hoverDebounce = useDebounce(HOVER_RESPONSE_TIME) 41 | const { 42 | isTriggerByClick, isTriggerByHover, isTriggerByContextmenu 43 | } = getTriggerState(props.trigger) 44 | const { 45 | getDirection 46 | } = useDropdownContentDirection(triggerRef, contentRef, position, direction, visible, props) 47 | const { 48 | startObserving, stopObserving 49 | } = useResizeObserver(triggerRef, contentRef, adjustContentPosition) 50 | const { 51 | startIntersectionObserving, stopIntersectionObserving 52 | } = useIntersectionObserver(contentRef, adjustContentPosition) 53 | 54 | watch(visible, val => { 55 | emit('visible-change', val) 56 | 57 | if (val) { 58 | document.body.addEventListener('mousedown', outsideClick) 59 | startObserving() 60 | startIntersectionObserving() 61 | } else { 62 | stopObserving() 63 | stopIntersectionObserving() 64 | document.body.removeEventListener('mousedown', outsideClick) 65 | } 66 | }) 67 | 68 | function onDropdownOpen () { 69 | emit('open') 70 | } 71 | function onDropdownOpened () { 72 | emit('opened') 73 | } 74 | function onDropdownClose () { 75 | emit('close') 76 | } 77 | function onDropdownClosed () { 78 | emit('closed') 79 | } 80 | function open () { 81 | if (disabled.value) return 82 | 83 | adjustContentPosition() 84 | 85 | if (isTriggerByHover) { 86 | hoverDebounce(() => { visible.value = true }) 87 | } else { 88 | visible.value = true 89 | } 90 | } 91 | function close () { 92 | if (disabled.value && !visible.value) return 93 | 94 | if (isTriggerByHover) { 95 | hoverDebounce(() => { visible.value = false }) 96 | } else { 97 | visible.value = false 98 | } 99 | } 100 | const toggleVisible = () => { 101 | if (toggle.value) { 102 | return visible.value ? close() : open() 103 | } 104 | 105 | if (visible.value) return 106 | open() 107 | } 108 | 109 | /** 110 | * Handle click event outside the dropdown content 111 | * @param {MouseEvent} e - event object 112 | */ 113 | function outsideClick (e) { 114 | if (!visible.value) return 115 | if (!triggerRef.value || !contentRef.value) return 116 | 117 | const inTrigger = triggerRef.value.contains(e.target) 118 | const inContent = contentRef.value.contains(e.target) 119 | 120 | if (inTrigger) { 121 | return isTriggerByContextmenu && e.button === 0 ? close() : '' 122 | } 123 | 124 | if (!inContent) close() 125 | } 126 | function adjustContentPosition () { 127 | const result = getDirection() 128 | contentStyles.value.top = `${result.top}px` 129 | contentStyles.value.left = `${result.left}px` 130 | } 131 | const handleTriggerClick = e => { 132 | if (!isTriggerByClick || manual.value) return 133 | e.stopPropagation() 134 | toggleVisible() 135 | } 136 | const handleHoverEnter = () => isTriggerByHover && open() 137 | const handleHoverLeave = () => isTriggerByHover && close() 138 | const handleTriggerContextMenu = e => { 139 | if (!isTriggerByContextmenu || manual.value) return 140 | e.stopPropagation() 141 | e.preventDefault() 142 | 143 | position.value.x = e.pageX 144 | position.value.y = e.pageY 145 | open() 146 | } 147 | 148 | if (isTriggerByClick) { 149 | useEventListener(() => triggerRef.value, 'click', handleTriggerClick) 150 | } 151 | if (isTriggerByHover) { 152 | useEventListener(() => triggerRef.value, 'mouseenter', handleHoverEnter) 153 | useEventListener(() => triggerRef.value, 'mouseleave', handleHoverLeave) 154 | useEventListener(() => contentRef.value, 'mouseenter', handleHoverEnter) 155 | useEventListener(() => contentRef.value, 'mouseleave', handleHoverLeave) 156 | } 157 | if (isTriggerByContextmenu) { 158 | useEventListener(() => triggerRef.value, 'contextmenu', handleTriggerContextMenu) 159 | } 160 | 161 | const slotData = { 162 | disabled: computed(() => disabled.value), 163 | visible: computed(() => visible.value), 164 | adjust: adjustContentPosition, 165 | open, 166 | close, 167 | toggleVisible 168 | } 169 | 170 | provide(keyDropdown, slotData) 171 | provide(keyInternal, { 172 | contentStyles, 173 | setContentClassGetter: fn => { getContentClass.value = fn } 174 | }) 175 | provide(keyContainer, { 176 | appendTo, 177 | defer, 178 | transitionName, 179 | onDropdownOpen, 180 | onDropdownOpened, 181 | onDropdownClose, 182 | onDropdownClosed 183 | }) 184 | 185 | expose({ 186 | open, 187 | close, 188 | toggleVisible, 189 | adjust: adjustContentPosition, 190 | visible 191 | }) 192 | 193 | return { 194 | visible, 195 | open, 196 | close, 197 | toggleVisible, 198 | slotData, 199 | getContentClass, 200 | contentStyles 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/helper.js: -------------------------------------------------------------------------------- 1 | import { 2 | ROUNDED_SMALL, 3 | ROUNDED_MEDIUM, 4 | roundedList, 5 | contentRoundedList, 6 | TRIGGER_CLICK, 7 | TRIGGER_CONTEXTMENU, 8 | TRIGGER_HOVER 9 | } from './constants' 10 | 11 | export { getElementRect } from './util' 12 | 13 | export function getTriggerClasses (props) { 14 | return ['dd-trigger', 15 | { 'dd-trigger--block': props.block } 16 | ] 17 | } 18 | export function getRoundedClass (value) { 19 | const level = !value || !roundedList.includes(value) 20 | ? ROUNDED_MEDIUM 21 | : roundedList.find(val => val === value) 22 | return `dd-rounded--${level}` 23 | } 24 | export function getContentRoundedClass (value) { 25 | const level = !value || !contentRoundedList.includes(value) 26 | ? ROUNDED_SMALL 27 | : contentRoundedList.find(val => val === value) 28 | return `dd-content-rounded--${level}` 29 | } 30 | export function getTriggerState (trigger) { 31 | return { 32 | isTriggerByClick: trigger === TRIGGER_CLICK, 33 | isTriggerByHover: trigger === TRIGGER_HOVER, 34 | isTriggerByContextmenu: trigger === TRIGGER_CONTEXTMENU 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Dropdown from './Dropdown' 2 | import DropdownTrigger from './DropdownTrigger' 3 | import DropdownContent from './DropdownContent' 4 | import { useDropdown } from './use' 5 | 6 | export { 7 | Dropdown, 8 | DropdownTrigger, 9 | DropdownContent, 10 | useDropdown 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/_animate.sass: -------------------------------------------------------------------------------- 1 | 2 | @mixin transition-scale ($name, $origin) 3 | .#{$name}-enter-from, 4 | .#{$name}-leave-to 5 | transform: scale3d(0.95, 0.95, 1) translateZ(0) 6 | opacity: 0 7 | .#{$name}-enter-active, 8 | .#{$name}-leave-active 9 | backface-visibility: hidden 10 | will-change: opacity, transform 11 | // transform: translateZ(0) 12 | // perspective: 1000px 13 | contain: layout style paint 14 | .#{$name}-enter-active 15 | transform-origin: $origin 16 | transition: 17 | property: opacity, transform 18 | duration: 150ms 19 | timing-function: cubic-bezier(0.4, 0, 0.2, 1) 20 | .#{$name}-leave-active 21 | transform-origin: $origin 22 | transition: 23 | property: opacity, transform 24 | duration: 75ms 25 | timing-function: cubic-bezier(0.4, 0, 0.2, 1) 26 | 27 | @include transition-scale('drop-down-left', top right) 28 | @include transition-scale('drop-down-right', top left) 29 | @include transition-scale('drop-up-left', bottom right) 30 | @include transition-scale('drop-up-right', bottom left) 31 | -------------------------------------------------------------------------------- /src/styles/dropdown.sass: -------------------------------------------------------------------------------- 1 | @use 'animate' 2 | 3 | .dd-trigger 4 | display: inline-flex 5 | box-sizing: border-box 6 | width: fit-content 7 | &.dd-trigger--block 8 | display: flex 9 | width: auto 10 | .dd-content-body 11 | display: flex 12 | .dd-content 13 | position: absolute 14 | top: 0 15 | left: 0 16 | -webkit-font-smoothing: subpixel-antialiased 17 | display: inline-flex 18 | margin: 0 19 | padding: 0 20 | max-width: 80vw 21 | box-sizing: border-box 22 | background-color: white 23 | overflow: hidden 24 | // border: 1px solid #D6D7D7 25 | border: 1px solid #d0d0d0 26 | // z-index: 3000 27 | // box-shadow: 0 15px 25px rgb(0, 0, 0, 0.2) 28 | // box-shadow: 0 7px 15px rgb(0, 0, 0, 0.25) 29 | box-shadow: 0 9px 24px rgb(0, 0, 0, .18), 0 3px 6px rgb(0, 0, 0, .08) 30 | // box-shadow: 0px 3px 6px rgb(15, 15, 15, 0.1), 0px 9px 24px rgb(15, 15, 15, 0.2) 31 | &.dd-no-border 32 | border: 0 33 | 34 | .dd-rounded 35 | &--small 36 | border-radius: .25rem !important 37 | &--medium 38 | border-radius: .5rem !important 39 | &--large 40 | border-radius: .75rem !important 41 | &--pill 42 | border-radius: 50rem !important 43 | &--circle 44 | border-radius: 50% !important 45 | 46 | .dd-content-rounded 47 | &--small 48 | border-radius: 6px !important 49 | &--medium 50 | border-radius: 12px !important 51 | &--large 52 | border-radius: 18px !important 53 | -------------------------------------------------------------------------------- /src/styles/trigger.sass: -------------------------------------------------------------------------------- 1 | .dd-trigger-container 2 | display: inline-block 3 | &.dd-disabled, 4 | &.dd-disabled:hover 5 | .dd-default-trigger 6 | border: 1px solid #eee 7 | background-color: #eee 8 | cursor: default 9 | color: #aaa 10 | .dd-default-trigger 11 | display: inline-flex 12 | align-items: center 13 | padding: .5rem 1rem 14 | background-color: white 15 | border: 1px solid #ddd 16 | border-radius: .3rem 17 | font-size: 14px 18 | line-height: 1.42857143 19 | outline: 0 !important 20 | color: #666 21 | gap: .5rem 22 | transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out 23 | &:hover 24 | border: 1px solid #aaa 25 | color: black 26 | .dd-caret-down 27 | transition: transform .2s ease 28 | &.dd-opened 29 | // box-shadow: 0 0 0 3px rgb(0, 0, 0, 0.08) 30 | box-shadow: 3px 2px 6px rgb(0, 0, 0, 0.2) 31 | border: 1px solid #666 32 | color: black 33 | .dd-caret-down 34 | transform: rotate(180deg) 35 | &:hover 36 | border: 1px solid #666 37 | &.dd-rounded--circle 38 | width: 38px 39 | height: 38px 40 | padding: 0 41 | justify-content: center 42 | .dd-caret-down 43 | display: inline-block 44 | width: 0 45 | height: 0 46 | border-top: 4px solid 47 | border-left: 4px solid transparent 48 | border-right: 4px solid transparent 49 | /*float: right*/ 50 | /*margin-top: 3px*/ 51 | vertical-align: middle 52 | content: "" 53 | // position: absolute 54 | // top: 7px 55 | // right: 10px 56 | -------------------------------------------------------------------------------- /src/use.js: -------------------------------------------------------------------------------- 1 | import { ref, inject, onMounted, onBeforeUnmount, onUnmounted, toRef } from 'vue' 2 | import { getTriggerState } from './helper' 3 | import { getElementRect } from './util' 4 | import { 5 | keyDropdown, 6 | DIRECTION_UP, DIRECTION_DOWN, DIRECTION_LEFT, DIRECTION_CENTER, DIRECTION_RIGHT 7 | } from './constants' 8 | 9 | export const useDropdown = () => inject(keyDropdown, {}) 10 | export function useDebounce (time = 300) { 11 | let timer 12 | 13 | return fn => { 14 | clearTimeout(timer) 15 | timer = setTimeout(fn, time) 16 | } 17 | } 18 | export function useThrottle (delay = 300) { 19 | let timer = null 20 | return fn => { 21 | if (timer) return 22 | timer = setTimeout(() => { 23 | fn?.() 24 | timer = null 25 | }, delay) 26 | } 27 | } 28 | 29 | export function useDropdownContentDirection ( 30 | triggerRef, 31 | contentRef, 32 | position, 33 | direction, 34 | visible, 35 | props 36 | ) { 37 | const { trigger, gap } = props 38 | const { isTriggerByContextmenu } = getTriggerState(trigger) 39 | 40 | /** 41 | * Calculation display direction and top axis 42 | * @param {number} y 43 | * @param {DOMRect} triggerRect - trigger element bounding client rect 44 | * @param {DOMRect} contentRect - content element bounding client rect 45 | * @return {number} 46 | */ 47 | function getTop (y, triggerRect, contentRect) { 48 | // Reset direction when content is not visible 49 | if (!visible.value) { 50 | direction.value.vertical = DIRECTION_DOWN 51 | } 52 | 53 | const scrollTop = window.scrollY 54 | // The height value not include scroll bar 55 | const viewHeight = document.documentElement.clientHeight 56 | const startTop = isTriggerByContextmenu ? y : triggerRect.top + scrollTop 57 | const downwardTop = isTriggerByContextmenu 58 | ? y 59 | : triggerRect.top + triggerRect.height + gap + scrollTop 60 | const upwardTop = startTop - gap - contentRect.height 61 | // Is there enough space to expand downwards 62 | const overBelow = (downwardTop + contentRect.height) > (scrollTop + viewHeight) 63 | // Is there enough space to expand upwards 64 | const overAbove = upwardTop < scrollTop 65 | 66 | if (direction.value.vertical === DIRECTION_UP) { 67 | if (overAbove && !overBelow) { 68 | direction.value.vertical = DIRECTION_DOWN 69 | return downwardTop 70 | } 71 | return upwardTop 72 | } 73 | // Expand downwards by default 74 | if (!overAbove && overBelow) { 75 | direction.value.vertical = DIRECTION_UP 76 | return upwardTop 77 | } 78 | return downwardTop 79 | } 80 | /** 81 | * Calculation left axis 82 | * @param {number} x 83 | * @param {DOMRect} triggerRect - trigger element bounding client rect 84 | * @param {DOMRect} contentRect - content element bounding client rect 85 | * @returns {number} 86 | */ 87 | function getLeft (x, triggerRect, contentRect) { 88 | if (!visible.value) { 89 | direction.value.horizontal = DIRECTION_RIGHT 90 | } 91 | 92 | const scrollLeft = window.scrollX 93 | // The width value not include scroll bar 94 | const viewWidth = document.documentElement.clientWidth 95 | const triggerWidth = isTriggerByContextmenu ? 0 : triggerRect.width 96 | // Left axis of align left 97 | const leftOfAlignLeft = isTriggerByContextmenu ? x : triggerRect.left + scrollLeft 98 | // Left axis of align center 99 | const leftOfAlignCenter = (leftOfAlignLeft + (triggerWidth / 2)) - (contentRect.width / 2) 100 | // Left axis of align right 101 | const leftOfAlignRight = (leftOfAlignLeft + triggerWidth) - contentRect.width 102 | 103 | const isLeftOverRight = (leftOfAlignLeft + contentRect.width) > (scrollLeft + viewWidth) 104 | const isCenterOverRight = (leftOfAlignCenter + contentRect.width) > (scrollLeft + viewWidth) 105 | const isRightOverLeft = leftOfAlignRight < scrollLeft 106 | 107 | const align = toRef(props, 'align') 108 | 109 | if (align.value === DIRECTION_CENTER) { 110 | direction.value.horizontal = isCenterOverRight ? DIRECTION_LEFT : DIRECTION_RIGHT 111 | return isCenterOverRight 112 | ? leftOfAlignRight 113 | : isRightOverLeft ? leftOfAlignLeft : leftOfAlignCenter 114 | } 115 | if (align.value === DIRECTION_RIGHT) { 116 | direction.value.horizontal = isRightOverLeft ? DIRECTION_RIGHT : DIRECTION_LEFT 117 | return isRightOverLeft ? leftOfAlignLeft : leftOfAlignRight 118 | } 119 | // Align to left by default 120 | direction.value.horizontal = isLeftOverRight ? DIRECTION_LEFT : DIRECTION_RIGHT 121 | return isLeftOverRight ? leftOfAlignRight : leftOfAlignLeft 122 | } 123 | 124 | function getDirection () { 125 | const triggerRect = getElementRect(triggerRef.value) 126 | const contentRect = getElementRect(contentRef.value) 127 | return { 128 | top: getTop(position.value.y, triggerRect, contentRect), 129 | left: getLeft(position.value.x, triggerRect, contentRect) 130 | } 131 | } 132 | 133 | return { getDirection } 134 | } 135 | 136 | export function useIntersectionObserver (contentRef, handler) { 137 | let observer = null 138 | 139 | const options = { 140 | root: null, 141 | rootMargin: '0px', 142 | threshold: [0.5, 0.75, 1.0] 143 | } 144 | const EPS = 1e-7 145 | 146 | const handleObserver = entries => { 147 | const entry = entries[0] 148 | if (Math.abs(entry.intersectionRatio - 1) < EPS) return 149 | // console.log(entry) 150 | handler?.() 151 | } 152 | 153 | function startIntersectionObserving () { 154 | if (!contentRef.value) return 155 | 156 | if (!observer) { 157 | observer = new IntersectionObserver(handleObserver, options) 158 | } 159 | 160 | observer.observe(contentRef.value) 161 | } 162 | 163 | function stopIntersectionObserving () { 164 | if (!observer) return 165 | observer.unobserve(contentRef.value) 166 | } 167 | 168 | function cleanupObserver () { 169 | if (observer) { 170 | observer.disconnect() 171 | observer = null 172 | } 173 | } 174 | 175 | onBeforeUnmount(cleanupObserver) 176 | 177 | return { 178 | startIntersectionObserving, 179 | stopIntersectionObserving 180 | } 181 | } 182 | 183 | export function useResizeObserver (triggerRef, contentRef, handler) { 184 | const isObserving = ref(false) 185 | const skipFirst = ref(false) 186 | let observer = null 187 | 188 | const handleResize = () => { 189 | // Skip first time callback when ResizeObserver observe elements 190 | if (!skipFirst.value) { 191 | skipFirst.value = true 192 | return 193 | } 194 | 195 | handler?.() 196 | } 197 | 198 | const startObserving = () => { 199 | if (!triggerRef.value || !contentRef.value || isObserving.value) return 200 | 201 | if (!observer) { 202 | observer = new ResizeObserver(handleResize) 203 | } 204 | 205 | observer.observe(triggerRef.value) 206 | observer.observe(contentRef.value) 207 | isObserving.value = true 208 | } 209 | 210 | const stopObserving = () => { 211 | if (!observer || !isObserving.value) return 212 | 213 | if (triggerRef.value && contentRef.value) { 214 | observer.unobserve(triggerRef.value) 215 | observer.unobserve(contentRef.value) 216 | } 217 | 218 | isObserving.value = false 219 | skipFirst.value = false 220 | } 221 | 222 | const disconnect = () => { 223 | if (observer) { 224 | observer.disconnect() 225 | observer = null 226 | isObserving.value = false 227 | skipFirst.value = false 228 | } 229 | } 230 | 231 | onUnmounted(disconnect) 232 | 233 | return { 234 | startObserving, 235 | stopObserving 236 | } 237 | } 238 | /** 239 | * 240 | * @param {EventTarget | function} target 241 | * @param {string} event 242 | * @param {EventListenerOrEventListenerObject} handler 243 | * @param {boolean | AddEventListenerOptions} options 244 | * @returns 245 | */ 246 | export function useEventListener (target, event, handler, options) { 247 | let el = null 248 | 249 | const cleanup = () => { 250 | if (!el) return 251 | el.removeEventListener(event, handler, options) 252 | el = null 253 | } 254 | 255 | onMounted(() => { 256 | el = typeof target === 'function' ? target() : target 257 | el?.addEventListener(event, handler, options) 258 | }) 259 | 260 | onBeforeUnmount(cleanup) 261 | 262 | return cleanup 263 | } 264 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | function isHidden (el) { 2 | return window.getComputedStyle(el).display === 'none' 3 | } 4 | 5 | function getRect (el) { 6 | // 通过 getComputedStyle(el).width 获得的值更精准,精确到小数点后三位 7 | const rect = el.getBoundingClientRect() 8 | return { 9 | width: el.offsetWidth, 10 | height: el.offsetHeight, 11 | top: rect.top, 12 | left: rect.left 13 | } 14 | } 15 | export function getElementRect (el) { 16 | if (!el) { 17 | return { 18 | width: 0, 19 | height: 0, 20 | top: 0, 21 | left: 0 22 | } 23 | } 24 | if (isHidden(el)) { 25 | /** 26 | * change the way to hide dropdown container from 27 | * 'display:none' to 'visibility:hidden' 28 | * be used for get width and height 29 | */ 30 | el.style.visibility = 'hidden' 31 | el.style.display = 'inline-flex' 32 | const rect = getRect(el) 33 | /** 34 | * restore dropdown style after getting position data 35 | */ 36 | el.style.visibility = 'visible' 37 | el.style.display = 'none' 38 | return rect 39 | } 40 | return getRect(el) 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es5", 5 | "strict": true, 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "allowJs": true, 9 | "jsx": "preserve", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /types/content.d.ts: -------------------------------------------------------------------------------- 1 | import { VNode } from 'vue' 2 | import { ComponentProps } from './dropdown' 3 | 4 | declare interface ContentProps extends ComponentProps { 5 | /** 6 | * @default true 7 | */ 8 | border?: boolean 9 | /** 10 | * @default `small` 11 | */ 12 | rounded?: 'small' | 'medium' | 'large' 13 | /** 14 | * @default 3000 15 | */ 16 | zIndex?: number 17 | } 18 | 19 | declare interface DropdownContent { 20 | new (): { 21 | $props: ContentProps 22 | $slots: { 23 | default?: () => VNode[] 24 | } 25 | } 26 | } 27 | 28 | declare const DropdownContent: DropdownContent 29 | 30 | export { DropdownContent } 31 | -------------------------------------------------------------------------------- /types/dropdown.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AllowedComponentProps, 3 | ComponentCustomProps, 4 | VNodeProps, 5 | VNode, 6 | ComputedRef 7 | } from 'vue' 8 | 9 | export declare interface ComponentProps extends 10 | AllowedComponentProps, 11 | ComponentCustomProps, 12 | VNodeProps {} 13 | 14 | export declare interface DropdownUtilities { 15 | visible: ComputedRef 16 | disabled: ComputedRef 17 | /** Adjust content position */ 18 | adjust: () => void 19 | /** Open dropdown */ 20 | open: () => void 21 | /** Close dropdown */ 22 | close: () => void 23 | /** Toggle dropdown open and close */ 24 | toggleVisible: () => void 25 | } 26 | 27 | /** 28 | * Dropdown props 29 | */ 30 | declare interface Props { 31 | /** 32 | * Container show up alignment direction 33 | * @default `left` 34 | */ 35 | align?: 'left' | 'center' | 'right' 36 | /** 37 | * Toggle display / close dropdown container 38 | * @default true 39 | */ 40 | toggle?: boolean 41 | /** 42 | * @default true 43 | */ 44 | animated?: boolean 45 | /** 46 | * Manual control the display and hiding of dropdown 47 | * @default false 48 | */ 49 | manual?: boolean 50 | /** 51 | * Disabled the dropdown 52 | * @default false 53 | */ 54 | disabled?: boolean 55 | /** 56 | * Trigger container display mode 57 | * - false: inline 58 | * - true: block 59 | * @default false 60 | */ 61 | block?: boolean 62 | /** 63 | * Dropdown trigger method 64 | * - `click` 65 | * - `hover` 66 | * - `contextmenu` 67 | * @default `click` 68 | */ 69 | trigger?: 'click' | 'hover' | 'contextmenu' 70 | /** 71 | * @default 5 72 | */ 73 | gap?: number 74 | /** 75 | * 76 | * @default `body` 77 | */ 78 | appendTo?: string | HTMLElement 79 | } 80 | declare interface TriggerProps extends ComponentProps { 81 | rounded?: 'small' | 'medium' | 'large' | 'pill' | 'circle' 82 | } 83 | 84 | /** Dropdown container visible change event */ 85 | type EmitVisibleChange = (event: 'visible-change', value: boolean) => void 86 | type EmitOpen = (event: 'open') => void 87 | type EmitOpened = (event: 'opened') => void 88 | type EmitClose = (event: 'close') => void 89 | type EmitClosed = (event: 'closed') => void 90 | 91 | declare interface Dropdown { 92 | new (): { 93 | $props: Props 94 | $emit: EmitVisibleChange & EmitOpen & EmitOpened & EmitClose & EmitClosed 95 | $slots: { 96 | default?: (slotData: DropdownUtilities) => VNode[] 97 | trigger?: (slotData: DropdownUtilities) => VNode[] 98 | } 99 | } 100 | } 101 | declare interface DropdownTrigger { 102 | new (): { 103 | $props: TriggerProps 104 | $slots: { 105 | default?: () => VNode[] 106 | append?: () => VNode[] 107 | } 108 | } 109 | } 110 | 111 | /** The dropdown container */ 112 | declare const Dropdown: Dropdown 113 | declare const DropdownTrigger: DropdownTrigger 114 | 115 | export declare function useDropdown (): DropdownUtilities 116 | 117 | export { Dropdown, DropdownTrigger } 118 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './content' 2 | export { 3 | Dropdown, 4 | DropdownTrigger, 5 | DropdownUtilities, 6 | useDropdown 7 | } from './dropdown' 8 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { resolve } from 'path' 3 | // import { defineConfig } from 'vite' 4 | import { defineConfig } from 'vitest/config' 5 | import vue from '@vitejs/plugin-vue' 6 | import vueJsx from '@vitejs/plugin-vue-jsx' 7 | import cssInJs from 'vite-plugin-css-injected-by-js' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | }, 16 | build: { 17 | lib: { 18 | entry: resolve(__dirname, 'src/index.js'), 19 | name: 'VDropdown', 20 | formats: ['es', 'umd'], 21 | fileName: 'v-dropdown' 22 | }, 23 | rollupOptions: { 24 | external: ['vue'], 25 | output: { 26 | globals: { 27 | vue: 'Vue' 28 | } 29 | } 30 | } 31 | }, 32 | test: { 33 | environment: 'jsdom', 34 | reporters: 'verbose', 35 | coverage: { 36 | provider: 'v8', 37 | reporter: ['text', 'json', 'html'], 38 | include: ['src/**'] 39 | }, 40 | setupFiles: ['src/__tests__/setup.js'] 41 | }, 42 | plugins: [ 43 | vue(), 44 | vueJsx(), 45 | cssInJs() 46 | ] 47 | }) 48 | --------------------------------------------------------------------------------