├── .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 · [](https://github.com/TerryZ/v-dropdown/actions/workflows/npm-publish.yml) [](https://codecov.io/gh/TerryZ/v-dropdown) [](https://www.npmjs.com/package/v-dropdown) [](https://www.npmjs.com/package/v-dropdown) [](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://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 |
48 |
49 |
50 | some contents
51 |
52 |
53 |
54 |
55 |
58 | ```
59 |
60 | ### Custom trigger content
61 |
62 | ```vue
63 |
64 |
65 |
66 | Click me
67 |
68 |
69 |
70 | some contents
71 |
72 |
73 |
74 |
75 |
78 | ```
79 |
80 | ## License
81 |
82 | [](https://mit-license.org/)
83 |
--------------------------------------------------------------------------------
/examples/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
--------------------------------------------------------------------------------
/examples/CustomContent.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
0123456789
4 |
0123456789
5 |
0123456789
6 |
7 | visible: {{ visible }}
8 |
9 |
10 | disabled: {{ disabled }}
11 |
12 |
16 | Close
17 |
18 |
22 | Adjust
23 |
24 |
25 |
26 |
27 |
36 |
--------------------------------------------------------------------------------
/examples/ExamplesIndex.vue:
--------------------------------------------------------------------------------
1 |
74 |
75 |
76 |
77 |
78 | v-dropdown examples
79 |
80 |
81 |
82 |
83 |
84 | Trigger by click
85 |
86 |
87 |
88 |
89 | Trigger rounded:
90 |
94 |
95 | small
96 |
97 |
98 | medium
99 |
100 |
101 | large
102 |
103 |
104 | pill
105 |
106 |
107 | circle
108 |
109 |
110 |
111 |
112 | Container rounded:
113 |
117 |
118 | small
119 |
120 |
121 | medium
122 |
123 |
124 | large
125 |
126 |
127 |
128 |
129 |
132 |
133 |
134 |
135 |
136 |
137 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
138 |
139 |
140 |
141 |
142 |
143 |
144 | Trigger by hover
145 |
146 |
149 |
150 |
151 |
159 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
0123456789012345678901234567890123456789
170 |
0123456789012345678901234567890123456789
171 |
0123456789012345678901234567890123456789
172 |
0123456789012345678901234567890123456789
173 |
0123456789012345678901234567890123456789
174 |
0123456789012345678901234567890123456789
175 |
0123456789012345678901234567890123456789
176 |
0123456789012345678901234567890123456789
177 |
0123456789012345678901234567890123456789
178 |
179 |
184 | Close dropdown
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 | Trigger by context menu(Block display)
194 |
195 |
196 | Align direction:
197 |
201 |
202 | Left
203 |
204 |
205 | Center
206 |
207 |
208 | Right
209 |
210 |
211 |
212 |
219 |
220 |
227 | Mouse right click this area
228 |
229 |
230 |
231 |
232 |
0123456789012345678901234567890123456789
233 |
0123456789012345678901234567890123456789
234 |
0123456789012345678901234567890123456789
235 |
0123456789012345678901234567890123456789
236 |
0123456789012345678901234567890123456789
237 |
0123456789012345678901234567890123456789
238 |
0123456789012345678901234567890123456789
239 |
0123456789012345678901234567890123456789
240 |
0123456789012345678901234567890123456789
241 |
242 |
243 |
244 |
245 |
246 | Disabled loop switch dropdown
247 |
248 |
249 |
254 |
255 |
259 |
260 |
261 |
262 |
0123456789
263 |
0123456789
264 |
0123456789
265 |
266 |
267 |
268 |
269 |
274 | open
275 |
276 |
281 | close
282 |
283 |
284 |
285 |
286 | Borderless and gap
287 |
288 |
289 |
290 |
291 |
292 |
293 | visible: {{ dropVisible }}, disabled: {{ dropDisabled }}
294 |
295 |
296 |
297 |
298 |
0123456789012345678901234567890123456789
299 |
0123456789012345678901234567890123456789
300 |
0123456789012345678901234567890123456789
301 |
0123456789012345678901234567890123456789
302 |
0123456789012345678901234567890123456789
303 |
0123456789012345678901234567890123456789
304 |
0123456789012345678901234567890123456789
305 |
0123456789012345678901234567890123456789
306 |
0123456789012345678901234567890123456789
307 |
308 |
309 |
310 |
311 |
312 |
313 | Gap:
314 |
318 |
319 | 5px
320 |
321 |
322 | 10px
323 |
324 |
325 | 20px
326 |
327 |
328 |
329 |
330 |
331 |
332 | Disabled
333 |
334 |
335 |
341 | Disabled to open dropdown
345 |
346 |
347 | z-index:
348 |
352 |
353 | unset
354 |
355 |
356 | 1000
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
0123456789
368 |
0123456789
369 |
0123456789
370 |
371 |
372 |
373 |
374 |
375 |
376 | Manual open / close dropdown
377 |
378 |
403 |
404 |
405 | Trigger custom class
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
0123456789
414 |
0123456789
415 |
0123456789
416 |
417 |
418 |
419 |
420 |
421 | Cover mode ?
422 |
423 |
424 |
428 |
429 |
430 |
431 |
432 |
433 |
0123456789012345678901234567890123456789
434 |
0123456789012345678901234567890123456789
435 |
0123456789012345678901234567890123456789
436 |
0123456789012345678901234567890123456789
437 |
0123456789012345678901234567890123456789
438 |
0123456789012345678901234567890123456789
439 |
0123456789012345678901234567890123456789
440 |
0123456789012345678901234567890123456789
441 |
0123456789012345678901234567890123456789
442 |
443 |
444 |
445 |
446 |
447 |
448 | Container size change
449 |
450 |
457 |
458 |
459 |
464 |
465 |
466 |
473 |
474 | Open
475 |
476 |
477 |
478 |
479 |
483 |
484 |
489 | Change size
490 |
491 |
496 | add item
497 |
498 |
503 | remove items
504 |
505 |
506 |
507 |
512 |
513 |
514 |
515 |
516 |
517 |
518 | Specify width with events
519 |
520 |
521 | 不指定 trigger 插槽,v-dropdown 应渲染默认的内置按钮
522 |
523 |
524 |
532 |
533 |
534 |
535 |
536 |
540 | change disabled
541 |
542 |
543 |
544 |
545 |
546 |
547 |
548 |
549 |
554 | Render
555 |
556 |
561 | Remove
562 |
563 |
564 |
565 |
566 |
子组件独立使用时避免出现脚本错误的情况
567 |
568 |
569 | Trigger
570 |
571 |
572 |
573 |
574 |
575 |
576 | Content
577 |
578 |
579 |
580 |
581 |
0123456789012345678901234567890123456789
582 |
0123456789012345678901234567890123456789
583 |
0123456789012345678901234567890123456789
584 |
0123456789012345678901234567890123456789
585 |
0123456789012345678901234567890123456789
586 |
0123456789012345678901234567890123456789
587 |
0123456789012345678901234567890123456789
588 |
0123456789012345678901234567890123456789
589 |
0123456789012345678901234567890123456789
590 |
591 |
592 |
593 |
594 |
595 |
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 |
38 |
39 |
40 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | abc123
9 |
10 |
11 |
12 |
13 |
14 |
21 |
--------------------------------------------------------------------------------
/src/__tests__/components/PropsToDropdownTrigger.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
13 |
14 |
15 | abc123
16 |
17 |
18 |
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 |
--------------------------------------------------------------------------------