diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000000..f831890c41 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,138 @@ +{ + "projectName": "vue-devui", + "projectOwner": "DevCloudFE", + "repoType": "github", + "repoHost": "https://github.com", + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": true, + "commitConvention": "gitmoji", + "contributors": [ + { + "login": "kagol", + "name": "Kagol", + "avatar_url": "https://avatars.githubusercontent.com/u/9566362?v=4", + "profile": "https://juejin.cn/user/712139267650141", + "contributions": [ + "maintenance", + "code", + "doc" + ] + }, + { + "login": "TinsFox", + "name": "TinsFox", + "avatar_url": "https://avatars.githubusercontent.com/u/33956589?v=4", + "profile": "https://github.com/TinsFox", + "contributions": [ + "maintenance", + "infra" + ] + }, + { + "login": "lnzhangsong", + "name": "nif", + "avatar_url": "https://avatars.githubusercontent.com/u/15092594?v=4", + "profile": "https://github.com/lnzhangsong", + "contributions": [ + "code" + ] + }, + { + "login": "Zcating", + "name": "Zcating", + "avatar_url": "https://avatars.githubusercontent.com/u/13329558?v=4", + "profile": "https://github.com/Zcating", + "contributions": [ + "maintenance", + "code" + ] + }, + { + "login": "sufuwang", + "name": "王凯", + "avatar_url": "https://avatars.githubusercontent.com/u/46395105?v=4", + "profile": "https://github.com/sufuwang", + "contributions": [ + "code" + ] + }, + { + "login": "iel-h", + "name": "iel", + "avatar_url": "https://avatars.githubusercontent.com/u/53589602?v=4", + "profile": "https://github.com/iel-h", + "contributions": [ + "maintenance", + "code" + ] + }, + { + "login": "chenxi24", + "name": "chenxi24", + "avatar_url": "https://avatars.githubusercontent.com/u/40349890?v=4", + "profile": "https://github.com/chenxi24", + "contributions": [ + "code" + ] + }, + { + "login": "asdlml6", + "name": "小九九", + "avatar_url": "https://avatars.githubusercontent.com/u/61737780?v=4", + "profile": "https://github.com/asdlml6", + "contributions": [ + "code" + ] + }, + { + "login": "AlanLee97", + "name": "AlanLee", + "avatar_url": "https://avatars.githubusercontent.com/u/42601044?v=4", + "profile": "http://blog.alanlee.top", + "contributions": [ + "code" + ] + }, + { + "login": "ForeseeBear", + "name": "Echo", + "avatar_url": "https://avatars.githubusercontent.com/u/15258339?v=4", + "profile": "https://github.com/ForeseeBear", + "contributions": [ + "code" + ] + }, + { + "login": "GaoNeng-wWw", + "name": "GaoNeng", + "avatar_url": "https://avatars.githubusercontent.com/u/31283122?v=4", + "profile": "https://github.com/GaoNeng-wWw", + "contributions": [ + "code" + ] + }, + { + "login": "xingyan95", + "name": "行言", + "avatar_url": "https://avatars.githubusercontent.com/u/11143986?v=4", + "profile": "https://github.com/xingyan95", + "contributions": [ + "code" + ] + }, + { + "login": "devin974", + "name": "devin", + "avatar_url": "https://avatars.githubusercontent.com/u/67035714?v=4", + "profile": "https://devin974.github.io/", + "contributions": [ + "code" + ] + } + ], + "contributorsPerLine": 7, + "skipCi": true +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..af30b8a0a8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line-length = off +trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..8c5f27c5df --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,113 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + ecmaVersion: 6, + ecmaFeatures: { + jsx: true, + tsx: true, + }, + }, + env: { + browser: true, + node: true, + jest: true, + es6: true, + }, + plugins: ['@typescript-eslint'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:vue/vue3-recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + ], + rules: { + 'no-useless-constructor': 'off', + 'no-useless-concat': 'off', + 'max-params': 'off', + '@typescript-eslint/no-useless-constructor': 'off', + '@typescript-eslint/no-parameter-properties': 'off', + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-var-requires': 'off', + complexity: [ + 'error', + { + max: 40, + }, + ], + curly: 'error', + 'eol-last': 'error', + eqeqeq: ['error', 'smart'], + 'max-len': ['error', { code: 140 }], + 'no-console': [ + 'error', + { + allow: [ + 'log', + 'warn', + 'dir', + 'timeLog', + 'assert', + 'clear', + 'count', + 'countReset', + 'group', + 'groupEnd', + 'table', + 'dirxml', + 'error', + 'groupCollapsed', + 'Console', + 'profile', + 'profileEnd', + 'timeStamp', + 'context', + ], + }, + ], + 'no-multiple-empty-lines': 'error', + 'no-shadow': 'off', + 'no-trailing-spaces': 'error', + 'no-unused-labels': 'error', + 'no-use-before-define': 'error', + 'no-var': 'error', + 'prefer-const': 'error', + semi: 'error', + 'space-in-parens': ['error', 'never'], + 'spaced-comment': ['error', 'always'], + '@typescript-eslint/dot-notation': 'off', + '@typescript-eslint/indent': [ + 'error', + 2, + { FunctionDeclaration: { parameters: 'first' }, FunctionExpression: { parameters: 'first' } }, + ], + '@typescript-eslint/member-delimiter-style': [ + 'error', + { + multiline: { + delimiter: 'semi', + requireLast: true, + }, + singleline: { + delimiter: 'semi', + requireLast: false, + }, + }, + ], + '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/prefer-function-type': 'error', + '@typescript-eslint/semi': ['error', 'always'], + '@typescript-eslint/type-annotation-spacing': 'error', + '@typescript-eslint/unified-signatures': 'error', + '@typescript-eslint/no-shadow': 'error', + 'prefer-promise-reject-errors': 'off', + 'max-nested-callbacks': ['error', 6], + '@typescript-eslint/no-this-alias': 'off', + 'accessor-pairs': 'off', + 'max-depth': 'off', + '@typescript-eslint/member-ordering': 'off', + 'array-callback-return': 'off', + }, +}; diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000000..6a6f648317 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,61 @@ +name: '🐛 Bug report' +description: Create a report to help us improve vue-devui +title: '🐛 [Bug]: ' +labels: ['🐛 bug'] +body: + - type: markdown + attributes: + value: | + Please fill out the following carefully in order to better fix the problem. + - type: input + id: devui-version + attributes: + label: Version + description: | + ### **Check if the issue is reproducible with the latest stable version.** + You can use the command `npm ls vue-devui` to view it + placeholder: latest + validations: + required: true + - type: input + id: vue-version + attributes: + label: Vue Version + placeholder: latest + validations: + required: true + - type: textarea + id: minimal-repo + attributes: + label: Link to minimal reproduction + description: | + **Provide a streamlined CodePen / CodeSandbox or GitHub repository link as much as possible. Please don't fill in a link randomly, it will only close your issue directly.** + placeholder: Please Input + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Step to reproduce + description: | + **After the replay is turned on, what actions do we need to perform to make the bug appear? Simple and clear steps can help us locate the problem more quickly. Please clearly describe the steps of reproducing the issue. Issues without clear reproducing steps will not be repaired. If the issue marked with 'need reproduction' does not provide relevant steps within 7 days, it will be closed directly.** + placeholder: Please Input + validations: + required: true + - type: textarea + id: expected + attributes: + label: What is expected + placeholder: Please Input + - type: textarea + id: actually + attributes: + label: What is actually happening + placeholder: Please Input + - type: textarea + id: additional-comments + attributes: + label: Any additional comments (optional) + description: | + **Some background / context of how you ran into this bug.** + placeholder: Please Input diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..f53f178517 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Questions or need help + url: https://github.com/DevCloudFE/vue-devui/discussions + about: Add this WeChat(devui-official), we will invite you to the WeChat discussion group later. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000000..4838fd60ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,23 @@ +name: ✨ Feature Request +description: Propose new features to vue-devui to improve it. +title: '✨ [Feature]: ' +labels: ['✨ feature'] +body: + - type: textarea + id: feature-solve + attributes: + label: What problem does this feature solve + description: | + Explain your use case, context, and rationale behind this feature request. More importantly, what is the end user experience you are trying to build that led to the need for this feature? + placeholder: Please Input + validations: + required: true + - type: textarea + id: feature-api + attributes: + label: What does the proposed API look like + description: | + Describe how you propose to solve the problem and provide code samples of how the API would work once implemented. Note that you can use Markdown to format your code blocks. + placeholder: Please Input + validations: + required: true diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000000..d8646c75e5 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,41 @@ +name: Pull Request + +on: + push: + branches: [ dev, main ] + pull_request: + branches: [ dev, main ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [16.x] + + name: "Build: node-${{ matrix.node-version }}" + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 6 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install deps + run: pnpm i + + - name: Build site + run: pnpm build + + # - name: Test vue-devui + # run: yarn test diff --git a/.github/workflows/sync-gitee.yml b/.github/workflows/sync-gitee.yml new file mode 100644 index 0000000000..c08cb6030a --- /dev/null +++ b/.github/workflows/sync-gitee.yml @@ -0,0 +1,20 @@ +name: Sync to Gitee + +on: + push: + branches: [dev, main, gh-pages] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Sync to Gitee + uses: wearerequired/git-mirror-action@master + env: + # 在 Settings->Secrets 配置 SSH_PRIVATE_KEY + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + with: + # GitHub 源仓库地址 + source-repo: git@github.com:DevCloudFE/vue-devui.git + # Gitee 目标仓库地址 + destination-repo: git@gitee.com:devui/vue-devui.git diff --git a/.gitignore b/.gitignore index 399d9de0dc..19dc38196f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ build lib types *.local -pnpm-lock.yaml package-lock.json +yarn.lock yarn-error.log .history .vscode @@ -16,4 +16,3 @@ packages/devui-vue/devui/vue-devui.ts packages/devui-vue/devui/theme/theme.scss packages/devui-vue/docs/.vitepress/config/sidebar.ts packages/devui-vue/docs/.vitepress/config/enSidebar.ts - diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000000..a789998f5e --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install commitlint --edit \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..65d5bb7c48 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx @ls-lint/ls-lint && npx lint-staged diff --git a/packages/devui-vue/.ls-lint.yml b/.ls-lint.yml similarity index 100% rename from packages/devui-vue/.ls-lint.yml rename to .ls-lint.yml diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..6c59086d86 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true diff --git a/.prettierrc b/.prettierrc index 752af08930..2083fb89e3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,12 +1,13 @@ { - "arrowParens": "always", "bracketSpacing": true, - "htmlWhitespaceSensitivity": "ignore", + "jsxBracketSameLine": true, "jsxSingleQuote": true, - "printWidth": 100, - "semi": false, + "printWidth": 140, + "semi": true, "useTabs": false, - "trailingComma": "none", + "trailingComma": "es5", "singleQuote": true, - "tabWidth": 2 + "tabWidth": 2, + "endOfLine": "auto", + "proseWrap": "preserve" } diff --git a/packages/devui-vue/.stylelintrc.json b/.stylelintrc.json similarity index 100% rename from packages/devui-vue/.stylelintrc.json rename to .stylelintrc.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..35b5167093 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CODE_OF_CONDUCT.zh-CN.md b/CODE_OF_CONDUCT.zh-CN.md new file mode 100644 index 0000000000..c2b0fa0cdf --- /dev/null +++ b/CODE_OF_CONDUCT.zh-CN.md @@ -0,0 +1,87 @@ +# 贡献者公约 + +## 我们的承诺 + +身为项目成员、贡献者、负责人,我们保证参与此社区的每个人都不受骚扰,不论其年龄、体型、身体条件、民族、性征、性别认同与表现、经验水平、教育程度、社会地位、国籍、相貌、种族、宗教信仰及性取向如何。 + +我们承诺致力于建设开放、友善、多元、包容、健康的社区环境。 + +## 我们的准则 + +有助于促进本社区积极环境的行为包括但不限于: + +* 与人为善、推己及人 +* 尊重不同的主张、观点和经历 +* 积极提出、耐心接受有益批评 +* 面对过失,承担责任、认真道歉、从中学习 +* 关注社区共同诉求,而非一己私利 + +不当行为包括但不限于: + +* 发布与性有关的言论或图像,以及任何形式的献殷勤或勾引 +* 挑衅行为、侮辱或贬损的言论、人身及政治攻击 +* 公开或私下骚扰 +* 未获明确授权擅自发布他人的资料,如地址、电子邮箱等 +* 其他有理由认定为违反职业操守的不当行为 + +## 落实之义务 + +社区负责人有责任诠释何谓“妥当行为”,并据此准则,妥善公正地认定与处置不当、威胁、冒犯及有害的行为。 + +社区负责人有权利和义务删除、编辑、拒绝违背本公约的评论(comment)、提交(commit)、代码、维基(wiki)编辑、问题(issue)等贡献。如有必要,需告知采取措施之理由。 + +## 适用范围 + +此行为标准适用于本社区全部场合,以及在其他场合代表本社区的个人。 + +代表本社区的情形包括但不限于:使用官方电子邮件与社交平台、作为指定代表参与在线或线下活动。 + +## 贯彻落实 + +如遇滥用、骚扰等不当行为,请通过[在此输入联系方式]向纪律检查委员举报。 +纪委将迅速审议并调查全部投诉。 + +社区全体负责人有义务保密举报者信息。 + +## 指导方针 + +社区负责人将依据下列方案判断并处置违纪行为: + +### 一、督促 + +**社区影响**:用语不当、举止不符合职业道德或不受社区欢迎。 + +**处理意见**:由社区负责人予以非公开的书面警告,阐明违纪事由、解释举止如何不妥。或将要求公开道歉。 + +### 二、警告 + +**社区影响**:一起或多起事件中的违纪行为。 + +**处理意见**:警告继续违纪之后果、违纪者在特定时间内禁止与当事人往来、不得擅自与社区执法者往来,禁令涵盖社区内外、社交网络在内的一切联络。如有违反,可致封禁乃至开除。 + +### 三、封禁 + +**社区影响**:严重违纪行为,包括屡教不改。 + +**处理意见**:违纪者在特定时间内禁止与社区的任何往来或公开联络,禁止任何与当事人公开或私下往来,不得擅自与社区执法者往来。如有违反,可致开除。 + +### 四、开除 + +**社区影响**:典型违纪行为,例如屡教不改、骚扰某个人、敌对或贬低某个群体。 + +**处理意见**:无限期禁止违纪者与项目社区的一切公开往来。 + +## 来源 + +本行为标准改编自[参与者公约][homepage]2.0版,可在此查阅:[https://www.contributor-covenant.org/zh-cn/version/2/0/code_of_conduct.html][v2.0] + +指导方针借鉴自[Mozilla纪检分级][Mozilla CoC]。 + +此行为标准常见问题请洽:[https://www.contributor-covenant.org/faq][FAQ]。 +另有诸译本:[https://www.contributor-covenant.org/translations][translations]。 + +[homepage]:https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 120000 index 0000000000..6a6d479819 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +packages/devui-vue/docs/CONTRIBUTING.md \ No newline at end of file diff --git a/README.md b/README.md index b04d6e0fb9..3c3278701f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Vue DevUI 是 Vue3 版本的 DevUI 组件库,基于 [https://github.com/devclo DevUI 官方网站:[https://devui.design](https://devui.design) -# 当前状态: Beta +## 当前状态: Beta 该项目还处于孵化和演进阶段,欢迎大家参与到 Vue DevUI 项目的建设中来!🎉🎉 @@ -18,51 +18,41 @@ DevUI 官方网站:[https://devui.design](https://devui.design) - ⭐ 参与到开源社区中来 - 🎊 结识一群热爱学习、热爱开源的朋友 -# 快速开始 +[贡献指南](https://github.com/DevCloudFE/vue-devui/wiki/%E8%B4%A1%E7%8C%AE%E6%8C%87%E5%8D%97) -## 1 安装依赖 +## 快速开始 -``` -yarn(推荐) - -or +### 1 安装依赖 -npm i ``` - -## 2 启动 - +pnpm i ``` -yarn dev(推荐) -or +### 2 启动 -npm run dev +``` +pnpm dev ``` -## 3 访问 +### 3 访问 [http://localhost:3000/](http://localhost:3000/) -## 4 生产打包 +### 4 生产打包 ``` -yarn build(推荐) - -or - -npm run build +pnpm build ``` -# 使用 Vue DevUI +## 使用 Vue DevUI -## 1. 安装 +### 1. 安装 ``` -yarn add vue-devui +pnpm i vue-devui ``` -## 2. 全量引入 +### 2. 全量引入 在`main.ts`文件中编写以下代码: ``` @@ -79,7 +69,7 @@ createApp(App) .mount('#app') ``` -## 3. 按需引入 +### 3. 按需引入 除了全量引入,我们也支持单个组件按需引入。 @@ -99,7 +89,7 @@ createApp(App) .mount('#app') ``` -## 4. 配置自动按需引入`unplugin-vue-components`(推荐) +### 4. 配置自动按需引入`unplugin-vue-components`(推荐) 配置`unplugin-vue-components`插件可以无需引入Vue DevUI就可以直接按需使用其中的组件,具体使用方式如下: @@ -124,7 +114,7 @@ export default defineConfig({ 配置了以上插件,就可以直接在代码中使用`Vue DevUI`的组件,而无需在`main.ts`文件中引入`Vue DevUI`。 -## 5. 使用 +### 5. 使用 ``` ``` -# 图标库 +## 图标库 图标库推荐使用[DevUI图标库](https://devui.design/icon/ruleResource),也可以使用第三方图标库,比如:iconfont。 -## 使用DevUI图标库 +### 使用DevUI图标库 -### 安装 +#### 安装 ``` -yarn add @devui-design/icons(推荐) - -or - -npm i @devui-design/icons +pnpm i @devui-design/icons ``` -### 引入 +#### 引入 在`main.ts`文件中,编写以下代码: @@ -156,12 +142,46 @@ npm i @devui-design/icons import '@devui-design/icons/icomoon/devui-icon.css' ``` -### 使用 +#### 使用 ``` ``` -# License +## Contributors ✨ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + +

Kagol

🚧 💻 📖

TinsFox

🚧 🚇

nif

💻

Zcating

🚧 💻

王凯

💻

iel

🚧 💻

chenxi24

💻

小九九

💻

AlanLee

💻

Echo

💻

GaoNeng

💻

行言

💻

devin

💻
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! + +## License [MIT](https://github.com/DevCloudFE/vue-devui/blob/dev/LICENSE) diff --git a/packages/devui-vue/commitlint.config.js b/commitlint.config.js similarity index 100% rename from packages/devui-vue/commitlint.config.js rename to commitlint.config.js diff --git a/lerna.json b/lerna.json deleted file mode 100644 index 793042fdd1..0000000000 --- a/lerna.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "packages": [ - "packages/*" - ], - "version": "0.0.0", - "npmClient": "yarn", - "useWorkspaces": true -} diff --git a/package.json b/package.json index 17d3c41413..29a4b8b794 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,34 @@ "name": "root", "private": true, "scripts": { - "dev": "lerna exec --scope vue-devui yarn dev", - "build": "lerna exec --scope vue-devui yarn build", - "build:lib": "lerna exec --scope vue-devui yarn build:lib" + "dev": "pnpm dev --filter vue-devui", + "build": "pnpm build --filter vue-devui", + "build:lib": "pnpm build:lib --filter vue-devui", + "build:cli": "pnpm build --filter devui-cli", + "prepare": "husky install", + "precommit": "lint-staged", + "lint:fix": "eslint --fix \"packages/**/{*.vue,*.js,*.ts,*.jsx,*.tsx}\"", + "stylelint": "stylelint --fix \"packages/**/{*.scss,*.css}\"", + "test": "pnpm test --filter vue-devui" }, "devDependencies": { - "lerna": "^4.0.0" + "@commitlint/cli": "^11.0.0", + "@ls-lint/ls-lint": "^1.10.0", + "all-contributors-cli": "^6.20.0", + "esbuild-register": "^2.6.0", + "eslint": "^7.28.0", + "eslint-plugin-import": "^2.24.2", + "eslint-plugin-vue": "^7.11.1", + "husky": "^7.0.4", + "lint-staged": "^11.0.0", + "npm-run-all": "^4.1.5", + "stylelint": "^13.13.1", + "stylelint-config-recommended-scss": "^4.3.0", + "stylelint-config-standard": "^22.0.0", + "stylelint-scss": "^3.20.1" }, - "workspaces": [ - "packages/*" - ] -} \ No newline at end of file + "lint-staged": { + ".{js,ts,jsx,tsx,vue}": "eslint --fix", + ".{scss,css}": "stylelint --fix" + } +} diff --git a/packages/devui-cli/README.md b/packages/devui-cli/README.md index 57f82fe97a..08441a36db 100644 --- a/packages/devui-cli/README.md +++ b/packages/devui-cli/README.md @@ -1,3 +1,55 @@ -# `devui-cli` +# DevUI CLI -> TODO: description \ No newline at end of file +`DevUI CLI`是一个为组件库而生的脚手架工具,它有以下特性: +1. 创建组件库模板 +2. 创建组件库入口文件 + +## 快速开始 + +### 本地启动 + +```sh +pnpm dev --filter devui-cli +``` + +成功启动之后将生成`devui-cli/lib/bin.js`文件,`dev`命令是可以热加载的,修改完代码不需要重新运行该命令。 + +运行该文件,即可进入创建组件的交互式命令行界面,按照引导创建组件模板或组件库入口文件。 + +```sh +pnpm cli --filter devui-cli create +``` + +### 构建 + +```sh +pnpm build --filter devui-cli +``` + +构建成功之后将生成`devui-cli/lib/bin.js`文件,这是最终的产物,用于发布 npm 包。 + +## 命令 + +目前支持以下命令: +- create +- build:进行中 + +### create + +在当前目录下创建组件模板、组件库入口文件等。 + +主要有以下问答选项: +- 选择一个类型 + - `component`: 已完成,创建组件模板 + - `component-test`: 进行中 + - `component-doc`: 进行中 + - `lib-entry`: 已完成,会根据创建的组件来自动生成入口文件 + - `doc-nav`: 进行中 +- 输入组件名称:将以该名称创建组件文件夹和组件名 +- 输入组件中文名:文档中的组件标题将显示该名称 +- 选择组件类型:`通用`、`导航`、`反馈`、`数据录入`、`数据展示`、`布局` +- 选择组件包含的形式(多选):`component`、`service`、`directive` + +### build + +进行中 diff --git a/packages/devui-cli/package.json b/packages/devui-cli/package.json index b0f4cb468b..60ce4304f5 100644 --- a/packages/devui-cli/package.json +++ b/packages/devui-cli/package.json @@ -1,19 +1,19 @@ { - "name": "@devui/cli", - "version": "0.0.1", - "description": "Cli of devui", + "name": "devui-cli", + "version": "0.0.2", + "description": "DevUI CLI", "keywords": [ "cli", "devui", "devui-cli" ], "author": "iel", - "homepage": "https://gitee.com/devui/vue-devui/tree/dev/packages/devui-vue/devui-cli#README.md", + "homepage": "https://github.com/DevCloudFE/vue-devui", "license": "MIT", "main": "lib/bin.js", - "types": "types/bin.d.ts", + "types": "types/config.d.ts", "bin": { - "dc": "lib/bin.js" + "devui": "lib/bin.js" }, "files": [ "lib", @@ -21,13 +21,12 @@ ], "repository": { "type": "git", - "url": "https://gitee.com/RootWater/vue-devui.git" + "url": "git@github.com:DevCloudFE/vue-devui.git" }, "scripts": { "dev": "esbuild --bundle ./src/bin.ts --format=cjs --platform=node --outfile=./lib/bin.js --external:esbuild --minify-whitespace --watch", - "build": "npm run build:lib & npm run build:dts", + "build": "run-p build:lib", "build:lib": "rimraf ./lib && esbuild --bundle ./src/bin.ts --format=cjs --platform=node --outfile=./lib/bin.js --external:esbuild --minify-whitespace", - "build:dts": "rimraf ./types && tsc -p ./tsconfig.json", "cli": "node ./lib/bin.js" }, "devDependencies": { @@ -41,6 +40,7 @@ "kolorist": "^1.5.0", "lodash-es": "^4.17.21", "prompts": "^2.4.2", + "rimraf": "^3.0.2", "typescript": "^4.4.4" }, "dependencies": { diff --git a/packages/devui-cli/src/bin.ts b/packages/devui-cli/src/bin.ts index bf19b38a6e..b802d9943f 100644 --- a/packages/devui-cli/src/bin.ts +++ b/packages/devui-cli/src/bin.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node import { Command } from 'commander' +import type { CliConfig } from '../types/config' import baseAction from './commands/base' import createAction, { validateCreateType } from './commands/create' -import { CliConfig, detectCliConfig } from './shared/config' +import { detectCliConfig } from './shared/config' import { VERSION } from './shared/constant' import { DEFAULT_CLI_CONFIG_FILE_NAME diff --git a/packages/devui-cli/src/commands/base.ts b/packages/devui-cli/src/commands/base.ts index 877dc955ad..78122a6910 100644 --- a/packages/devui-cli/src/commands/base.ts +++ b/packages/devui-cli/src/commands/base.ts @@ -1,68 +1,68 @@ -import { existsSync, statSync } from 'fs-extra' -import { dirname, extname, isAbsolute, resolve } from 'path' -import prompts from 'prompts' -import { mergeCliConfig } from '../shared/config' -import { CWD } from '../shared/constant' -import generateConfig from '../shared/generate-config' -import logger from '../shared/logger' -import { dynamicImport, onPromptsCancel } from '../shared/utils' -import buildAction from './build' -import createAction from './create' +import { existsSync, statSync } from 'fs-extra'; +import { dirname, extname, isAbsolute, resolve } from 'path'; +import prompts from 'prompts'; +import { mergeCliConfig } from '../shared/config'; +import { CWD } from '../shared/constant'; +import generateConfig from '../shared/generate-config'; +import logger from '../shared/logger'; +import { dynamicImport, onPromptsCancel } from '../shared/utils'; +import buildAction from './build'; +import createAction from './create'; function getActions() { - const actionMap = new Map() + const actionMap = new Map(); actionMap.set('create', { title: 'create', value: 'create', selected: true, action: createAction - }) - actionMap.set('build', { title: 'build', value: 'build', action: buildAction }) + }); + actionMap.set('build', { title: 'build', value: 'build', action: buildAction }); - return actionMap + return actionMap; } export type BaseCmd = { init?: boolean config?: string -} +}; export default async function baseAction(cmd: BaseCmd) { if (cmd.init) { - return generateConfig() + return generateConfig(); } - loadCliConfig(cmd) + loadCliConfig(cmd); - selectCommand() + selectCommand(); } export function loadCliConfig(cmd: Pick) { - if (!cmd.config) return + if (!cmd.config) return; - let configPath = resolve(CWD, cmd.config) + const configPath = resolve(CWD, cmd.config); if (!existsSync(configPath)) { - logger.error(`The path "${configPath}" not exist.`) - process.exit(1) + logger.error(`The path "${configPath}" not exist.`); + process.exit(1); } if (statSync(configPath).isDirectory() || !['.js', '.ts'].includes(extname(configPath))) { - logger.error(`The path "${configPath}" is not a ".js" or ".ts" file.`) - process.exit(1) + logger.error(`The path "${configPath}" is not a ".js" or ".ts" file.`); + process.exit(1); } - const config = dynamicImport(configPath) - if (!isAbsolute(config.cwd)) { - config.cwd = resolve(dirname(configPath), config.cwd) + const config = dynamicImport(configPath); + if (config.cwd && !isAbsolute(config.cwd)) { + config.cwd = resolve(dirname(configPath), config.cwd); } - mergeCliConfig(config) + mergeCliConfig(config); } async function selectCommand() { - const actions = getActions() - let result: any = {} + const actions = getActions(); + let result: any = {}; try { result = await prompts( @@ -77,11 +77,11 @@ async function selectCommand() { { onCancel: onPromptsCancel } - ) + ); } catch (e: any) { - logger.error(e.message) - process.exit(1) + logger.error(e.message); + process.exit(1); } - actions.get(result.command)!.action() + actions.get(result.command)!.action(); } diff --git a/packages/devui-cli/src/shared/config.ts b/packages/devui-cli/src/shared/config.ts index e4818a33ec..c52a78cc7a 100644 --- a/packages/devui-cli/src/shared/config.ts +++ b/packages/devui-cli/src/shared/config.ts @@ -1,71 +1,11 @@ import { readdirSync, statSync } from 'fs-extra' import { merge } from 'lodash-es' import { resolve } from 'path' +import type { CliConfig } from '../../types/config' import { loadCliConfig } from '../commands/base' import { CWD } from './constant' import { DEFAULT_CLI_CONFIG_NAME } from './generate-config' -export type CliConfig = { - /** - * current workspace directory - * - * ***Should be the root directory of the component library.*** - * - * @default process.cwd() - */ - cwd: string - /** - * Generate the root directory of component. - * - * ***Note that the path should be based on the `cwd` of configuration item.*** - * - * @default . - */ - componentRootDir: string - /** - * category of component - * - * @default ['通用', '导航', '反馈', '数据录入', '数据展示', '布局'] - */ - componentCategories: string[] - /** - * prefix of the component library - * - * @default '' - */ - libPrefix: string - /** - * component style file suffix of the component library - * - * @default .css - */ - libStyleFileSuffix: string - /** - * component class prefix of the component library - */ - libClassPrefix: string - /** - * component library entry file name - * - * @default index - */ - libEntryFileName: string - /** - * Generate the root directory of the lib entry file. - * - * ***Note that the path should be based on the `cwd` of configuration item.*** - * - * @default . - */ - libEntryRootDir: string - /** - * version of component library - * - * @default 0.0.0 - */ - version: string -} - export const cliConfig: CliConfig = { cwd: CWD, componentRootDir: '.', diff --git a/packages/devui-cli/src/shared/generate-lib-entry.ts b/packages/devui-cli/src/shared/generate-lib-entry.ts index fa19170b03..9e7a705e53 100644 --- a/packages/devui-cli/src/shared/generate-lib-entry.ts +++ b/packages/devui-cli/src/shared/generate-lib-entry.ts @@ -3,7 +3,7 @@ import { getComponentsMeta } from '../templates/component/utils' import genLibEntryTemplate from '../templates/lib-entry/lib-entry' import logger from './logger' -export default async function genLibEntry(filePath: string = '') { +export default async function genLibEntry(filePath = '') { const componentsMeta = await getComponentsMeta() writeFileSync(filePath, genLibEntryTemplate(componentsMeta), { diff --git a/packages/devui-cli/src/shared/utils.ts b/packages/devui-cli/src/shared/utils.ts index 882bed6bfd..3ff20f8142 100644 --- a/packages/devui-cli/src/shared/utils.ts +++ b/packages/devui-cli/src/shared/utils.ts @@ -4,6 +4,7 @@ import { camelCase, upperFirst } from 'lodash-es' import { extname, relative, resolve } from 'path' import { coreFileName } from '../templates/component/utils' import { cliConfig } from './config' +import { PKG_NAME } from './constant' export function bigCamelCase(str: string) { return upperFirst(camelCase(str)) @@ -35,7 +36,7 @@ export function dynamicImport(path: string) { outfile: tempPath, platform: 'node', format: 'cjs', - external: ['esbuild', 'dev-cli'] + external: ['esbuild', PKG_NAME] }) const config = require(relativePath).default ?? {} diff --git a/packages/devui-cli/src/templates/base/config.ts b/packages/devui-cli/src/templates/base/config.ts index 5e2798cfaf..14b00105b5 100644 --- a/packages/devui-cli/src/templates/base/config.ts +++ b/packages/devui-cli/src/templates/base/config.ts @@ -1,5 +1,5 @@ -import { CliConfig } from "../../shared/config"; -import { PKG_NAME } from "../../shared/constant"; +import type { CliConfig } from '../../../types/config'; +import { PKG_NAME } from '../../shared/constant'; export default function genConfigTemplate(config: Partial = {}) { return `\ diff --git a/packages/devui-cli/src/templates/component/doc.ts b/packages/devui-cli/src/templates/component/doc.ts index 641d6667fb..aad6bb48b7 100644 --- a/packages/devui-cli/src/templates/component/doc.ts +++ b/packages/devui-cli/src/templates/component/doc.ts @@ -1,5 +1,5 @@ -import { getPartName } from "../lib-entry/lib-entry"; -import { ComponentMeta } from "./meta"; +import { getPartName } from '../lib-entry/lib-entry'; +import { ComponentMeta } from './meta'; export default function genDocTemplate(meta: ComponentMeta) { return `\ diff --git a/packages/devui-cli/src/templates/component/style.ts b/packages/devui-cli/src/templates/component/style.ts index 9f7f217aa2..57a00df38e 100644 --- a/packages/devui-cli/src/templates/component/style.ts +++ b/packages/devui-cli/src/templates/component/style.ts @@ -1,4 +1,4 @@ -import { coreClassName } from "./utils" +import { coreClassName } from './utils' export default function genStyleTemplate(name: string) { return `\ diff --git a/packages/devui-cli/src/templates/component/utils.ts b/packages/devui-cli/src/templates/component/utils.ts index a36a4f5a20..7e87005af8 100644 --- a/packages/devui-cli/src/templates/component/utils.ts +++ b/packages/devui-cli/src/templates/component/utils.ts @@ -24,9 +24,8 @@ export const directiveName = (name: string) => bigCamelCase(name + 'Directive') export async function getComponentMetaFiles() { return glob('./**/meta.json', { - cwd: cliConfig.cwd, - absolute: true, - deep: 2 + cwd: cliConfig.componentRootDir, + absolute: true }) } diff --git a/packages/devui-cli/src/templates/lib-entry/lib-entry.ts b/packages/devui-cli/src/templates/lib-entry/lib-entry.ts index 9450fe0bd2..7ad6221b62 100644 --- a/packages/devui-cli/src/templates/lib-entry/lib-entry.ts +++ b/packages/devui-cli/src/templates/lib-entry/lib-entry.ts @@ -58,7 +58,7 @@ export default function genLibEntryTemplate(componentsMeta: ComponentMeta[]) { return `\ import type { App } from 'vue' -${imports.join('\n')} +${imports.join('\n') || '// Not find components.'} const installs = [ \t${installs.join(',\n\t')} diff --git a/packages/devui-cli/tsconfig.json b/packages/devui-cli/tsconfig.json index e799e8ac90..5a626cfed3 100644 --- a/packages/devui-cli/tsconfig.json +++ b/packages/devui-cli/tsconfig.json @@ -8,10 +8,8 @@ "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, - "declaration": true, - "emitDeclarationOnly": true, "downlevelIteration": true, - "declarationDir": "./types", + "typeRoots": ["./node_modules/@types", "./types"], "allowSyntheticDefaultImports": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, @@ -20,6 +18,7 @@ }, "include": [ "./src/**/*.ts", - "./src/**/*.d.ts" + "./src/**/*.d.ts", + "./types/**/*.d.ts" ] } \ No newline at end of file diff --git a/packages/devui-theme/.gitignore b/packages/devui-theme/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/packages/devui-theme/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/devui-theme/package.json b/packages/devui-theme/package.json new file mode 100644 index 0000000000..fec81f7386 --- /dev/null +++ b/packages/devui-theme/package.json @@ -0,0 +1,25 @@ +{ + "name": "devui-theme", + "version": "0.0.1", + "main": "src/index.ts", + "module": "src/index.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build" + }, + "dependencies": { + "css-vars-ponyfill": "^2.4.7", + "enquirejs-ssr": "^2.1.7", + "rxjs": "^7.5.4", + "vue": "^3.2.25" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^2.2.0", + "typescript": "^4.5.4", + "vite": "^2.8.0", + "vue-tsc": "^0.29.8" + } +} \ No newline at end of file diff --git a/packages/devui-theme/src/index.ts b/packages/devui-theme/src/index.ts new file mode 100644 index 0000000000..de2843a78e --- /dev/null +++ b/packages/devui-theme/src/index.ts @@ -0,0 +1,2 @@ +export * from './theme'; +export * from './theme-collection'; diff --git a/packages/devui-theme/src/styles-var/devui-var.less b/packages/devui-theme/src/styles-var/devui-var.less new file mode 100644 index 0000000000..0f1fc7c91c --- /dev/null +++ b/packages/devui-theme/src/styles-var/devui-var.less @@ -0,0 +1 @@ +// empty file wait to fill in diff --git a/packages/devui-theme/src/styles-var/devui-var.scss b/packages/devui-theme/src/styles-var/devui-var.scss new file mode 100644 index 0000000000..7152d5fc56 --- /dev/null +++ b/packages/devui-theme/src/styles-var/devui-var.scss @@ -0,0 +1,6 @@ +@import './private/color'; +@import './private/font'; +@import './private/shadow'; +@import './private/corner'; +@import './private/animation'; +@import './private/z-index'; diff --git a/packages/devui-theme/src/styles-var/private/_animation.scss b/packages/devui-theme/src/styles-var/private/_animation.scss new file mode 100644 index 0000000000..0209cefaf7 --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_animation.scss @@ -0,0 +1,9 @@ +$devui-animation-duration-slow: var(--devui-animation-duration-slow, 300ms); +$devui-animation-duration-base: var(--devui-animation-duration-base, 200ms); +$devui-animation-duration-fast: var(--devui-animation-duration-fast, 100ms); + +$devui-animation-ease-in: var(--devui-animation-ease-in, cubic-bezier(0.5, 0, 0.84, 0.25)); +$devui-animation-ease-out: var(--devui-animation-ease-out, cubic-bezier(0.16, 0.75, 0.5, 1)); +$devui-animation-ease-in-out: var(--devui-animation-ease-in-out, cubic-bezier(0.5, 0.05, 0.5, 0.95)); +$devui-animation-ease-in-out-smooth: var(--devui-animation-ease-in-out-smooth, cubic-bezier(0.645, 0.045, 0.355, 1)); +$devui-animation-linear: var(--devui-animation-linear, cubic-bezier(0, 0, 1, 1)); diff --git a/packages/devui-theme/src/styles-var/private/_color.scss b/packages/devui-theme/src/styles-var/private/_color.scss new file mode 100755 index 0000000000..84cc2926a0 --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_color.scss @@ -0,0 +1,100 @@ +// 基础变量 +$devui-global-bg: var(--devui-global-bg, #f3f6f8); // 全局带底色背景 +$devui-global-bg-normal: var(--devui-global-bg-normal, #ffffff); // 全局白色背景 +$devui-base-bg: var(--devui-base-bg, #ffffff); // 基础区块背景白色 +$devui-base-bg-dark: var(--devui-base-bg-dark, #333854); // 基础区块背景深色(固定) +$devui-brand: var(--devui-brand, #5e7ce0); // 品牌色 +$devui-brand-foil: var(--devui-brand-foil, #859bff); // 品牌色辅助色、正衬色 +$devui-brand-hover: var(--devui-brand-hover, #7693f5); // 品牌色高亮色(加亮) +$devui-brand-active: var(--devui-brand-active, #526ecc); // 品牌色激活色(加深) +$devui-brand-active-focus: var(--devui-brand-active-focus, #344899); // 品牌色焦点色(重度加深) +$devui-contrast: var(--devui-contrast, #c7000b); // 品牌色撞色、对比色、反衬色、第二品牌色 +$devui-text: var(--devui-text, #252b3a); // 正文文本 +$devui-text-weak: var(--devui-text-weak, #575d6c); // 弱化的正文信息(手风琴子项,表头) +$devui-aide-text: var(--devui-aide-text, #8a8e99); // 辅助文本、帮助信息(面包屑) +$devui-aide-text-stress: var(--devui-aide-text-stress, #575d6c); // 辅助文本、帮助信息里的强调色 +$devui-placeholder: var(--devui-placeholder, #8a8e99); // 占位符 +$devui-light-text: var(--devui-light-text, #ffffff); // 有色深色背景下字体颜色(固定) +$devui-dark-text: var(--devui-dark-text, #252b3a); // 有色浅色背景下字体颜色(固定) +$devui-link: var(--devui-link, #526ecc); // 链接文本颜色 +$devui-link-active: var(--devui-link-active, #344899); // 链接文本悬停/激活颜色 +$devui-link-light: var(--devui-link-light, #96adfa); // 深色背景下链接文本颜色 +$devui-link-light-active: var(--devui-link-light-active, #beccfa); // 深色背景下链接文本悬停/激活颜色 +$devui-line: var(--devui-line, #adb0b8); // 边框分割线,仅用于边框 +$devui-dividing-line: var(--devui-dividing-line, #dfe1e6); // 内容分割线,用于内容之间的分割 +$devui-block: var(--devui-block, #ffffff); // 大面积的不可折叠区块的背景色(例如顶部导航背景色) +$devui-area: var(--devui-area, #f8f8f8); // 可折叠区块的背景色(展开区域颜色) +$devui-danger: var(--devui-danger, #f66f6a); // 失败、错误、告警 +$devui-warning: var(--devui-warning, #fac20a); // 警告、需注意类型提示 +$devui-waiting: var(--devui-waiting, #9faad7); // 等待中 +$devui-success: var(--devui-success, #50d4ab); // 成功、正确 +$devui-info: var(--devui-info, #5e7ce0); // 通知、一般提示、执行中 +$devui-initial: var(--devui-initial, #e9edfa); // 初始化、未执行 +$devui-unavailable: var(--devui-unavailable, #f5f5f6); // 不可用、禁用状态 +$devui-shadow: var(--devui-shadow, rgba(0, 0, 0, 0.2)); // 阴影色 +$devui-light-shadow: var(--devui-light-shadow, rgba(0, 0, 0, 0.1)); // 弱化阴影色 + +// 图标 +$devui-icon-text: var(--devui-icon-text, #252b3a); // 文字图标颜色,同 正文颜色 +$devui-icon-bg: var(--devui-icon-bg, #ffffff); // svg图标 背景色 +$devui-icon-fill: var(--devui-icon-fill, #d3d5d9); // svg图标 灰色填充色 +$devui-icon-fill-hover: var(--devui-icon-fill-hover, #adb5ce); // svg图标 灰色填充色悬停反馈色 +$devui-icon-fill-active: var(--devui-icon-fill-active, #5e7ce0); // svg图标 高亮填充色(激活状态) +$devui-icon-fill-active-hover: var(--devui-icon-fill-active-hover, #526ecc); // svg图标 高亮填充色悬停反馈色 +// 表单 +$devui-form-control-line: var(--devui-form-control-line, #adb0b8); // 表单控件边框色,同 边框分割线 +$devui-form-control-line-hover: var(--devui-form-control-line-hover, #575d6c); // 表单控件边框悬停反馈色 +$devui-form-control-line-active: var(--devui-form-control-line-active, #5e7ce0); // 表单控件边框激活色,用于获得焦点 +$devui-form-control-line-active-hover: var(--devui-form-control-line-active-hover, #344899); // 表单控件边框激活色,用于获得焦点且悬停 +// 列表 +$devui-list-item-active-bg: var(--devui-list-item-active-bg, #5e7ce0); // 列表类型单选选中背景 +$devui-list-item-active-text: var(--devui-list-item-active-text, #ffffff); // 列表类型单选选中背景搭配文本,同 有色深色背景下字体颜色(固定) +$devui-list-item-active-hover-bg: var(--devui-list-item-active-hover-bg, #526ecc); // 列表类型单选选中背景悬停反馈色(仅用于分页等) +$devui-list-item-hover-bg: var(--devui-list-item-hover-bg, #f2f5fc); // 列表类型普通选项悬停背景 +$devui-list-item-hover-text: var(--devui-list-item-hover-text, #526ecc); // 列表类型普通选项悬停背景搭配文本 +$devui-list-item-selected-bg: var(--devui-list-item-selected-bg, #e9edfa); // 列表类型多选被选中的行色,仅用于表格类 +$devui-list-item-strip-bg: var(--devui-list-item-strip-bg, #f2f5fc); // 列表类型斑马纹色,仅用于表格类 +// 禁用 +$devui-disabled-bg: var(--devui-disabled-bg, #f5f5f6); // disabled背景颜色 +$devui-disabled-line: var(--devui-disabled-line, #dfe1e6); // disabled边框颜色 +$devui-disabled-text: var(--devui-disabled-text, #adb0b8); // disabled文字颜色 +$devui-primary-disabled: var(--devui-primary-disabled, #beccfa); //主要按钮disabled状态文字颜色 +$devui-icon-fill-active-disabled: var(--devui-icon-fill-active-disabled, #beccfa); // svg图标激活状态禁用色 +// 特殊背景色 +$devui-label-bg: var(--devui-label-bg, #eef0f5); // 默认标签化选项背景色 +$devui-connected-overlay-bg: var(--devui-connected-overlay-bg, #ffffff); // 有连接点的弹出菜单层背景色 +$devui-connected-overlay-line: var(--devui-connected-overlay-line, #526ecc); // 有连接点的弹出菜单层边框色 +$devui-fullscreen-overlay-bg: var(--devui-fullscreen-overlay-bg, #ffffff); // 全屏类型的弹出内容层背景色(模态弹窗) +$devui-feedback-overlay-bg: var(--devui-feedback-overlay-bg, #464d6e); // 信息提示反馈类型的漂浮层背景色(toast、popover) +$devui-feedback-overlay-text: var(--devui-feedback-overlay-text, #dfe1e6); // 信息提示反馈类型的漂浮层背景色搭配文本色 +$devui-embed-search-bg: var(--devui-embed-search-bg, #f2f5fc); // 被嵌套的无边框搜索框背景色 +$devui-embed-search-bg-hover: var(--devui-embed-search-bg-hover, #eef0f5); // 被嵌套的无边框搜索框背景色 +$devui-float-block-shadow: var(--devui-float-block-shadow, rgba(94, 124, 224, 0.3)); // 特殊浮层背景色(待修正) +$devui-highlight-overlay: var(--devui-highlight-overlay, rgba(255, 255, 255, 0.8)); // 局部半透明全局浮层背景色(比如底部) +$devui-range-item-hover-bg: var(--devui-range-item-hover-bg, #e9edfa); // datepicker范围中的日期hover的反馈背景色 + +// 按钮 +$devui-primary: var(--devui-primary, #5e7ce0); // 主要按钮,同品牌色 +$devui-primary-hover: var(--devui-primary-hover, #7693f5); // 主要按钮悬停 +$devui-primary-active: var(--devui-primary-active, #344899); // 主要按钮激活 + +$devui-contrast-hover: var(--devui-contrast-hover, #d64a52); // 突出按钮悬停 +$devui-contrast-active: var(--devui-contrast-active, #b12220);// 突出按钮激活 + +// 状态 +$devui-danger-line: var(--devui-danger-line, #f66f6a); // 失败边框 +$devui-danger-bg: var(--devui-danger-bg, #ffeeed); // 失败底色 + +$devui-warning-line: var(--devui-warning-line, #fa9841);// 警告边框 +$devui-warning-bg: var(--devui-warning-bg, #fff3e8); // 警告底色 + +$devui-info-line: var(--devui-info-line, #5e7ce0); // 通知边框 +$devui-info-bg: var(--devui-info-bg, #f2f5fc); // 通知底色 + +$devui-success-line: var(--devui-success-line, #50d4ab);// 成功边框 +$devui-success-bg: var(--devui-success-bg, #edfff9); // 成功底色 +$devui-primary-line: var(--devui-primary-line, #5e7ce0);// 主要边框 +$devui-primary-bg: var(--devui-primary-bg, #f2f5fc); // 主要底色 + +$devui-default-line: var(--devui-default-line, #5e7ce0);// 默认边框 +$devui-default-bg: var(--devui-default-bg, #f3f6f8); // 默认底色 diff --git a/packages/devui-theme/src/styles-var/private/_corner.scss b/packages/devui-theme/src/styles-var/private/_corner.scss new file mode 100644 index 0000000000..517e64beef --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_corner.scss @@ -0,0 +1,5 @@ +//圆角变量 + +$devui-border-radius: var(--devui-border-radius, 2px); //一般圆角 +$devui-border-radius-feedback: var(--devui-border-radius-feedback, 4px); //反馈类圆角 +$devui-border-radius-card: var(--devui-border-radius-card, 6px); //卡片圆角 diff --git a/packages/devui-theme/src/styles-var/private/_font.scss b/packages/devui-theme/src/styles-var/private/_font.scss new file mode 100644 index 0000000000..d1acc6e835 --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_font.scss @@ -0,0 +1,21 @@ +// 字号大小变量 + +$devui-font-size: var(--devui-font-size, 12px); //正文、卡片副标题 +$devui-font-size-card-title: var(--devui-font-size-card-title, 14px); //卡片标题 +$devui-font-size-page-title: var(--devui-font-size-page-title, 16px); //页面标题 +$devui-font-size-modal-title: var(--devui-font-size-modal-title, 18px); //弹窗标题、数字 +$devui-font-size-price: var(--devui-font-size-price, 20px); //购买价格 +$devui-font-size-data-overview: var(--devui-font-size-data-overview, 24px); //数据总览 + +$devui-font-size-icon: var(--devui-font-size-icon, 16px); //图标大小 +$devui-font-size-sm: var(--devui-font-size-sm, 12px); //当组件size为'sm'时使用此字号大小 +$devui-font-size-md: var(--devui-font-size-md, 12px); //当组件size为''时使用此字号大小 +$devui-font-size-lg: var(--devui-font-size-lg, 14px); //当组件size为'lg'时使用此字号大小 + +$devui-font-title-weight: bold; //标题文字粗细 +$devui-font-content-weight: normal; //内容文字粗细 +$devui-line-height-base: 1.5; //规范行高 + +$font-title-weight: bold; +$font-content-weight: normal; +$line-height-base: 1.5; diff --git a/packages/devui-theme/src/styles-var/private/_shadow.scss b/packages/devui-theme/src/styles-var/private/_shadow.scss new file mode 100644 index 0000000000..a86b13c331 --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_shadow.scss @@ -0,0 +1,12 @@ +//阴影变量 + +$devui-shadow-length-base: var(--devui-shadow-length-base, 0 1px 4px 0); //直接铺陈在页面上方的元素 (card等) + +$devui-shadow-length-slide-left: var(--devui-shadow-length-slide-left, -2px 0 8px 0); //向左滑动时出现在右侧边缘的阴影 (dataTable固定右侧列向左滑动) +$devui-shadow-length-slide-right: var(--devui-shadow-length-slide-right, 2px 0 8px 0); //向右滑动时出现在左侧边缘的阴影 (dataTable固定左侧列向右滑动) +$devui-shadow-length-connected-overlay : var(--devui-shadow-connected-overlay, 0 2px 8px 0); //有连接点的弹出(覆盖)层 (DatePicker Cascader Select TagsInput Pagination的下拉菜单等) + +$devui-shadow-length-hover : var(--devui-shadow-length-hover, 0 4px 16px 0); //涉及到hover的地方 +$devui-shadow-length-feedback-overlay : var(--devui-shadow-length-feedback-overlay, 0 4px 16px 0); //信息提示反馈类 (PopOver Tooltip Toast StepsGuide等) + +$devui-shadow-length-fullscreen-overlay: var(--devui-shadow-fullscreen-overlay, 0 8px 40px 0); //无连接点的弹出(覆盖)层 (Drawer Modal ImagePreview等) diff --git a/packages/devui-theme/src/styles-var/private/_variables.scss b/packages/devui-theme/src/styles-var/private/_variables.scss new file mode 100755 index 0000000000..69c096e3b9 --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_variables.scss @@ -0,0 +1,43 @@ +@import './color'; +@import '../core/font'; +@import '../theme/font'; + +$text-color: $devui-text; +$font-family: 'HuaweiFont', 'Helvetica','Arial', 'PingFangSC-Regular', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑','Microsoft JhengHei'; +$font-variant-base: tabular-nums; +$body-background: $devui-base-bg; +$devui-font-size: $devui-font-size; // 12px +// $devui-font-size-modal-title: $devui-font-size-modal-title; // 18px +$font-size-page-title: $devui-font-size-page-title; // 16px +// $devui-font-size-card-title: $devui-font-size-card-title; // 14px +.devui-font-size-base { + font-size: $devui-font-size; // 12px +} + +.devui-font-base { + @include font-content(); // 12px normal 1.5 +} + +.devui-font-size-modal-title { + font-size: $devui-font-size-modal-title; // 18px +} + +.devui-font-modal-title { + @include font-title($devui-font-size-modal-title); // 18px bold 1.5 +} + +.devui-font-size-page-title { + font-size: $devui-font-size-page-title; // 16px +} + +.devui-font-page-title { + @include font-title(); // 16px bold 1.5 +} + +.devui-font-size-secondary-title { + font-size: $devui-font-size-card-title; // 14px +} + +.devui-font-secondary-title { + @include font-title($devui-font-size-card-title); // 14px bold 1.5 +} diff --git a/packages/devui-theme/src/styles-var/private/_z-index.scss b/packages/devui-theme/src/styles-var/private/_z-index.scss new file mode 100644 index 0000000000..7846b41414 --- /dev/null +++ b/packages/devui-theme/src/styles-var/private/_z-index.scss @@ -0,0 +1,12 @@ +// 临时层 +// 若存在遮罩,则遮罩基于对应z-index值-1 +$devui-z-index-full-page-overlay: var(--devui-z-index-full-page-overlay, 1080); // 全屏覆盖类元素 +$devui-z-index-dropdown: var(--devui-z-index-dropdown, 1052); // 下拉菜单,dropdown等 +$devui-z-index-pop-up: var(--devui-z-index-pop-up, 1060); // 提示类信息,popover,tooltip等 +$devui-z-index-modal: var(--devui-z-index-modal, 1050);// 弹窗, +$devui-z-index-drawer: var(--devui-z-index-drawer, 1040);// 抽屉板 +$devui-z-index-framework: var(--devui-z-index-framework, 1000);// 框架类元素,header,sideMenu等 + +// 内容层,根据需要设置,zIndex需小于临时层 + +// 背景层,根据需要设置,zIndex需小于内容层 diff --git a/packages/devui-theme/src/theme-collection/extend-theme.scss b/packages/devui-theme/src/theme-collection/extend-theme.scss new file mode 100644 index 0000000000..78a4f4e547 --- /dev/null +++ b/packages/devui-theme/src/theme-collection/extend-theme.scss @@ -0,0 +1,334 @@ +@import '../styles-var/devui-var.scss'; + +body[ui-theme='infinity-theme'], +body[ui-theme='sweet-theme'], +body[ui-theme='provence-theme'], +body[ui-theme='deep-theme'], +body[ui-theme='galaxy-theme'] { + // TODO: 组件支持全局配置默认尺寸参数后删除 + // button default size change to '32px' + .devui-btn:not(.devui-btn-xs):not(.devui-btn-sm):not(.devui-btn-lg) { + height: 32px; + line-height: 32px; + } + + d-tabs { + display: inline; + + --devui-font-size-card-title: 14px; + } + + d-tag { + --devui-font-size: var(--devui-font-size-sm, 12px); + } + // datepicker定制化处理高亮 + d-datepicker, + d-date-range-picker, + d-datepicker-range-single, + d-two-datepicker-single { + --devui-list-item-active-bg: var(--devui-brand, #5e7ce0); + --devui-list-item-active-hover-bg: var(--devui-brand, #526ecc); + --devui-list-item-active-text: var(--devui-light-text, #ffffff); + --devui-font-size: var(--devui-font-size-sm, 12px); + } + + // TODO: 表单底层统一使用一致的input,全局修改该input尺寸默认值即可 + // input default size change to '32px' + d-multi-auto-complete .multi-auto-complete label.multiple-label-auto-complete ul.devui-dropdown-origin { + padding: 3px 2px 0 2px; + min-height: 32px; + } + + d-search .devui-search { + .devui-input:not(.devui-input-sm):not(.devui-input-lg) { + height: 32px; + } + + .devui-search-icon { + line-height: 32px; + } + + .devui-search-clear:not(.devui-search-clear-lg):not(.devui-search-clear-sm) { + line-height: 32px; + } + } + + d-select .devui-form-group { + .devui-form-control { + &.devui-select-input { + height: 30px; + } + } + + .devui-select-list-wrapper.devui-form-control { + .devui-select-placeholder { + height: 28px; + line-height: 28px; + } + + ul.devui-select-tag-list { + height: 28px; + } + + .devui-select-tag-item { + margin: 3px 1px 0; + } + } + } + + d-tree-select { + .devui-tree-select .popper .popper-activator .devui-select-input.devui-tree-select-input { + padding: 4px 10px; + min-height: 32px; + max-height: 64px; + } + } + + input { + border-radius: $devui-border-radius; + } + + [dTextInput] { + height: 32px; + } + + .devui-form-controls input[type='text'], + .devui-form-controls input[type='password'], + [dTextInput] { + height: 32px; + } + + // select带有搜索存在两层ul + .devui-dropdown-menu > ul.devui-dropdown-menu-wrap { + // 存在搜索框时 + & > li { + padding: 12px 12px 0 12px; + } + + ul { + padding: 12px; + + li { + border-radius: $devui-border-radius; + } + } + } + + // TODO: 下拉专项整改增加offset设置 + .devui-dropdown-menu { + margin-top: 8px !important; + margin-bottom: 8px !important; + } + + // breadcrumb dropdown menu + div.devui-search-container { + padding: 12px 12px 0 12px; + } + // autoComplete等 + .devui-dropdown-menu > ul:not(.devui-dropdown-menu-wrap) { + padding: 12px; + + li { + border-radius: $devui-border-radius; + } + } + // dropdown + ul.devui-dropdown-menu { + padding: 12px; + + li .devui-dropdown-item { + border-radius: $devui-border-radius; + } + } + + d-tags-input { + .devui-tags-autocomplete .devui-suggestion-list { + padding: 12px !important; + } + } + + .devui-input-sm { + height: 24px; + } + + .devui-input-lg { + height: 46px; + } + + d-tabs .devui-nav { + display: block; + + &.devui-nav-tabs, + &.devui-nav-pills { + li a { + line-height: 32px !important; + } + } + } + + // 表单尺寸未统一,upload内部自定义了高度 + d-single-upload, + d-multiple-upload { + .devui-input-group .devui-form-control { + min-height: 32px !important; + + .devui-file-tag { + height: 26px !important; + } + } + } + + div.popper-container div.popper-container-scrollable div.devui-tree-select.devui-options-container { + padding: 12px; + } + + div.popper-container div.devui-tree-select span.devui-form-control-feedback { + top: 12px; + right: 12px; + } + + .table-row-selected { + td { + color: #ffffff !important; + } + } + + // pagination样式修改 + d-pagination { + div.devui-pagination ul.devui-pagination-list li:not(.disabled) { + cursor: pointer; + + a:hover, + span:hover, + a:focus, + span:focus { + background-color: transparent !important; + color: $devui-text !important; + border: 1px solid $devui-dividing-line !important; + box-shadow: 0 1px 3px 0 $devui-light-shadow !important; + } + + a:active { + background-color: transparent !important; + color: $devui-text !important; + } + + &.active a { + background-color: $devui-primary !important; + color: $devui-light-text !important; + cursor: pointer !important; + + &:hover { + background-color: $devui-primary !important; + color: $devui-light-text !important; + border: 1px solid transparent !important; + box-shadow: none !important; + } + } + + a.devui-pagination-link:hover:not(:active) svg g polygon { + fill: $devui-text !important; + } + + a.devui-pagination-link:active svg g polygon { + fill: $devui-text !important; + } + } + + ul.devui-pagination-list > li > a { + height: 28px !important; + min-width: 28px !important; + padding: 0 4px !important; + justify-content: center !important; + border: 1px solid transparent !important; + } + + ul.devui-pagination-sm > li > a { + height: 24px !important; + min-width: 24px !important; + } + + ul.devui-pagination-lg > li > a { + height: 40px !important; + min-width: 40px !important; + } + + .devui-pagination-list > li:first-child > a, + .devui-pagination-list > li:last-child > a { + padding: 0 !important; + height: 28px !important; + line-height: 28px !important; + } + + .devui-pagination-sm > li:first-child > a, + .devui-pagination-sm > li:last-child > a { + height: 24px !important; + line-height: 24px !important; + } + + .devui-pagination-lg > li:first-child > a, + .devui-pagination-lg > li:last-child > a { + height: 40px !important; + line-height: 40px !important; + } + + .devui-pagination-link { + height: 28px !important; + line-height: 28px !important; + } + + .devui-pagination-sm > li:first-child > a { + padding: 0 !important; + } + + .devui-pagination-sm > li:last-child > a { + padding: 0 !important; + } + } +} + +body[ui-theme='deep-theme'] { + .devui-tree-node__content.active { + .devui-tree-node__title { + color: #ffffff; + } + + svg.svg-icon rect { + stroke: #ffffff !important; + } + + svg.svg-icon rect:last-child { + fill: #ffffff !important; + } + } + + .table-row-selected { + td { + color: #ffffff !important; + } + } +} + +body[ui-theme='infinity-theme'] { + d-tabs .devui-nav { + --devui-brand: #252b3a; + --devui-brand-active: #252b3a; + } +} + +body[ui-theme='galaxy-theme'] { + d-tabs .devui-nav-slider { + --devui-tab-slider-bg: #313131; + --devui-text: #a3a3a3; + --devui-brand-active: #ffffff; + --devui-base-bg: #3f3f3f; + } + + d-button .devui-btn-common { + --devui-block: transparent; + } + + d-button .devui-btn-primary:disabled { + --devui-light-text: #838383; + } +} diff --git a/packages/devui-theme/src/theme-collection/extend-theme.ts b/packages/devui-theme/src/theme-collection/extend-theme.ts new file mode 100644 index 0000000000..f68888310c --- /dev/null +++ b/packages/devui-theme/src/theme-collection/extend-theme.ts @@ -0,0 +1,213 @@ +import { devuiDarkTheme, devuiLightTheme, Theme } from '../theme'; +export const infinityTheme: Theme = new Theme({ + id: 'infinity-theme', + name: '无限主题', + data: { ...devuiLightTheme.data, 'devui-brand-foil': '#F2F2F3', + 'devui-global-bg': '#F8F8FA', + 'devui-base-bg': '#ffffff', + 'devui-text': '#252B3A', + 'devui-aide-text': '#71757f', + 'devui-placeholder': '#babbc0', + 'devui-disabled-text': '#cfd0d3', + 'devui-disabled-bg': '#f5f5f6', + 'devui-line': '#D7D8DA', + 'devui-dividing-line': '#F2F2F3', + 'devui-list-item-hover-bg': '#F2F2F3', + 'devui-list-item-active-bg': '#F2F5FC', + 'devui-list-item-active-hover-bg': '#F2F5FC', + 'devui-list-item-selected-bg': '#F2F5FC', + 'devui-list-item-hover-text': '#252b3a', + 'devui-list-item-active-text': '#252B3A', + 'devui-form-control-line-hover': '#A3A6AC', + 'devui-form-control-line': '#D7D8DA', + 'devui-icon-text': '#babbc0', + 'devui-label-bg': '#E9EDFA', + 'devui-border-radius': '4px', + 'devui-font-size': '14px', + 'devui-font-size-md': '14px', + 'devui-font-size-card-title': '16px', + 'devui-shadow-length-fullscreen-overlay': '0 0 6px 0', + 'devui-border-radius-card': '4px'}, + extends: 'devui-light-theme', + isDark: false, +}); + +export const provenceTheme: Theme = new Theme({ + id: 'provence-theme', + name: '紫罗兰主题', + data: { + ...infinityTheme.data, + 'devui-brand': '#7B69EE', + 'devui-brand-foil': '#F5F5F9', + 'devui-brand-active-focus': '#7B69EE', + 'devui-primary-active': '#7B69EE', + 'devui-brand-hover': '#7B69EE', + 'devui-global-bg': '#f9fafb', + 'devui-base-bg': '#ffffff', + 'devui-text': '#070036', + 'devui-aide-text': '#717087', + 'devui-placeholder': '#babbc0', + 'devui-disabled-text': '#cfd0d3', + 'devui-disabled-bg': '#f5f5f6', + 'devui-line': '#E2E2E5', + 'devui-dividing-line': '#F2F2F3', + 'devui-list-item-hover-bg': '#F5F5F9', + 'devui-list-item-active-bg': '#7B69EE', + 'devui-list-item-active-hover-bg': '#7B69EE', + 'devui-list-item-selected-bg': '#F4F2FF', + 'devui-list-item-hover-text': '#252b3a', + 'devui-list-item-active-text': '#ffffff', + 'devui-form-control-line-hover': '#A3A6AC', + 'devui-form-control-line': '#D7D8DA', + 'devui-icon-text': '#babbc0', + 'devui-brand-active': '#7B69EE', + 'devui-primary': '#7B69EE', + 'devui-primary-hover': '#7B69EE', + 'devui-form-control-line-active': '#7B69EE', + 'devui-form-control-line-active-hover': '#7B69EE', + 'devui-icon-fill-active': '#7B69EE', + 'devui-icon-fill-active-hover': '#7B69EE', + 'devui-label-bg': '#F4F2FF', + 'devui-embed-search-bg': '#F4F2FF', + 'devui-connected-overlay-line': '#7B69EE', + 'devui-primary-disabled': '#d8d2fa', + 'devui-icon-fill-active-disabled': '#d8d2fa' + + }, + extends: 'infinity-theme', + isDark: false +}); + +export const sweetTheme: Theme = new Theme({ + id: 'sweet-theme', + name: '蜜糖主题', + data: { + ...infinityTheme.data, + 'devui-brand': '#ec66ab', + 'devui-brand-foil': '#f8f1f5', + 'devui-brand-active-focus': '#ec66ab', + 'devui-primary-active': '#ec66ab', + 'devui-brand-hover': '#ec66ab', + 'devui-global-bg': '#f9fafb', + 'devui-base-bg': '#ffffff', + 'devui-text': '#2f272f', + 'devui-aide-text': '#827d82', + 'devui-placeholder': '#bdb8bd', + 'devui-disabled-text': '#cbcacb', + 'devui-disabled-bg': '#f6f6f6', + 'devui-line': '#aea6ad', + 'devui-dividing-line': '#eae7e9', + 'devui-list-item-hover-bg': '#f8f1f5', + 'devui-list-item-active-bg': '#ffdcee', + 'devui-list-item-active-hover-bg': '#ffdcee', + 'devui-list-item-selected-bg': '#ffdcee', + 'devui-list-item-hover-text': '#252b3a', + 'devui-list-item-active-text': '#252b3a', + 'devui-form-control-line-hover': '#A3A6AC', + 'devui-form-control-line': '#D7D8DA', + 'devui-icon-text': '#babbc0', + 'devui-brand-active': '#ec66ab', + 'devui-primary': '#ec66ab', + 'devui-primary-hover': '#ec66ab', + 'devui-form-control-line-active': '#ec66ab', + 'devui-form-control-line-active-hover': '#ec66ab', + 'devui-icon-fill-active': '#ec66ab', + 'devui-icon-fill-active-hover': '#ec66ab', + 'devui-label-bg': '#ffdcee', + 'devui-embed-search-bg': '#ffdcee', + 'devui-connected-overlay-line': '#ec66ab', + 'devui-primary-disabled': '#fad1e6', + 'devui-icon-fill-active-disabled': '#fad1e6', + }, + extends: 'infinity-theme', + isDark: false +}); + +export const deepTheme: Theme = new Theme({ + id: 'deep-theme', + name: '深邃夜空主题', + data: { + ...infinityTheme.data, + 'devui-brand': '#252b3a', + 'devui-brand-foil': '#f3f4f7', + 'devui-brand-active-focus': '#252b3a', + 'devui-primary-active': '#252b3a', + 'devui-brand-active': '#252b3a', + 'devui-brand-hover': '#252b3a', + 'devui-global-bg': '#f7f8fa', + 'devui-base-bg': '#ffffff', + 'devui-text': '#252b3a', + 'devui-aide-text': '#505c7c', + 'devui-placeholder': '#9ba6bf', + 'devui-disabled-text': '#a8b1c7', + 'devui-disabled-bg': '#f7f8fa', + 'devui-line': '#cdd2df', + 'devui-dividing-line': '#e6e9ef', + 'devui-list-item-hover-bg': '#f3f4f7', + 'devui-list-item-active-bg': '#252b3a', + 'devui-list-item-active-hover-bg': '#252b3a', + 'devui-list-item-selected-bg': '#252b3a', + 'devui-list-item-hover-text': '#252b3a', + 'devui-list-item-active-text': '#ffffff', + 'devui-form-control-line-hover': '#A3A6AC', + 'devui-form-control-line': '#D7D8DA', + 'devui-icon-text': '#babbc0', + 'devui-primary': '#252b3a', + 'devui-primary-hover': '#252b3a', + 'devui-form-control-line-active': '#252b3a', + 'devui-form-control-line-active-hover': '#252b3a', + 'devui-icon-fill-active': '#252b3a', + 'devui-icon-fill-active-hover': '#252b3a', + 'devui-connected-overlay-line': '#252b3a', + 'devui-primary-disabled': '#bebfc4', + 'devui-icon-fill-active-disabled': '#bebfc4', + }, + extends: 'infinity-theme', + isDark: false +}); + +export const galaxyTheme: Theme = new Theme({ + id: 'galaxy-theme', + name: '追光主题', + data: { + ...devuiDarkTheme.data, + 'devui-brand-foil': '#F2F2F3', + 'devui-global-bg': '#000000', + 'devui-base-bg': '#1F1F1F', + 'devui-text': '#F5F5F5', + 'devui-aide-text': '#A3A3A3', + 'devui-placeholder': '#616161', + 'devui-disabled-text': '#838383', + 'devui-disabled-bg': '#3F3F3F', + 'devui-line': '#565656', + 'devui-dividing-line': '#303030', + 'devui-list-item-hover-bg': '#313131', + 'devui-list-item-active-bg': '#30333D', + 'devui-list-item-active-hover-bg': '#30333D', + 'devui-list-item-selected-bg': '#30333D', + 'devui-list-item-hover-text': '#F5F5F5', + 'devui-list-item-active-text': '#526ECC', + 'devui-primary-disabled': '#3f3f3f', + 'devui-form-control-line': '#565656', + 'devui-icon-text': '#A3A3A3', + 'devui-connected-overlay-bg': '#282828', + 'devui-fullscreen-overlay-bg': '#282828', + 'devui-warning-line': '#a2622a', + 'devui-warning-bg': '#4b2e14', + 'devui-success-line': '#27846b ', + 'devui-success-bg': '#123d32', + 'devui-danger-line': '#9f4844', + 'devui-danger-bg': '#4a2120', + 'devui-info-line': '#3c5091', + 'devui-info-bg': '#1c2543', + 'devui-default-bg': '#313131', + 'devui-border-radius': '4px', + 'devui-font-size': '14px', + 'devui-font-size-md': '14px', + 'devui-font-size-card-title': '16px', + 'devui-shadow-length-fullscreen-overlay': '0 0 6px 0', + 'devui-border-radius-card': '4px' + }, + extends: 'devui-dark-theme', + isDark: true +}); diff --git a/packages/devui-theme/src/theme-collection/index.ts b/packages/devui-theme/src/theme-collection/index.ts new file mode 100644 index 0000000000..7e1a213e3e --- /dev/null +++ b/packages/devui-theme/src/theme-collection/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/packages/devui-theme/src/theme-collection/public-api.ts b/packages/devui-theme/src/theme-collection/public-api.ts new file mode 100644 index 0000000000..dab9199773 --- /dev/null +++ b/packages/devui-theme/src/theme-collection/public-api.ts @@ -0,0 +1 @@ +export * from './extend-theme'; diff --git a/packages/devui-theme/src/theme/index.ts b/packages/devui-theme/src/theme/index.ts new file mode 100644 index 0000000000..7e1a213e3e --- /dev/null +++ b/packages/devui-theme/src/theme/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/packages/devui-theme/src/theme/key-config.ts b/packages/devui-theme/src/theme/key-config.ts new file mode 100644 index 0000000000..e55fcbdf06 --- /dev/null +++ b/packages/devui-theme/src/theme/key-config.ts @@ -0,0 +1,10 @@ +export const THEME_KEY = { + userLastPreferTheme : 'user-custom-theme', // localStorage 存储的上一次主题名字 + userLastPreferThemeData: 'user-custom-theme-data', // localStorage 存储的上一次主题的变量,用于骨架屏阶段快速恢复主题 + currentTheme: 'devuiCurrentTheme', // context(默认window) 当前主题名字 + themeCollection: 'devuiThemes', // context(默认window) 存储所有主题集合 + styleElementId: 'devuiThemeVariables', // DOM Style Element 的 id标识, 标记css变量声明的片段 + transitionStyleElementId: 'devuiThemeColorTransition', // DOM Style Element 的 id标识,标记临时使用的css颜色动画 + uiThemeAttributeName: 'ui-theme', // body 和 style元素标记用户数据 + themeService: 'devuiThemeService' // 全局window下的theme service实例 +}; diff --git a/packages/devui-theme/src/theme/media-query.ts b/packages/devui-theme/src/theme/media-query.ts new file mode 100644 index 0000000000..e159a33432 --- /dev/null +++ b/packages/devui-theme/src/theme/media-query.ts @@ -0,0 +1,54 @@ +import * as enquire from 'enquirejs-ssr'; +import { ReplaySubject } from 'rxjs'; + +export class PrefersColorSchemeMediaQuery { + static enquire = enquire; // prevent code optimization excluding enquire out + private prefersColorSchemeSubject = new ReplaySubject(1); + public prefersColorSchemeChange = this.prefersColorSchemeSubject.asObservable(); + + register() { + PrefersColorSchemeMediaQuery.enquire + .register.bind(enquire)(PrefersColorSchemeMediaQuery.Query.light, { + match: () => { + this.handleColorSchemeChange('light'); + } + }) + .register(PrefersColorSchemeMediaQuery.Query.dark, { + match: () => { + this.handleColorSchemeChange('dark'); + } + }); + this.prefersColorSchemeSubject.next(this.getInitValue()); + } + + unregister() { + PrefersColorSchemeMediaQuery.enquire + .unregister(PrefersColorSchemeMediaQuery.Query.light) + .unregister(PrefersColorSchemeMediaQuery.Query.dark); + this.prefersColorSchemeSubject.complete(); + } + + handleColorSchemeChange = (value: PrefersColorSchemeMediaQuery.Value) => { + this.prefersColorSchemeSubject.next(value); + }; + + getInitValue(): PrefersColorSchemeMediaQuery.Value { + if (typeof window === 'undefined') { + return 'light'; + } + + return window.matchMedia(PrefersColorSchemeMediaQuery.Query.light).matches && 'light' + || window.matchMedia(PrefersColorSchemeMediaQuery.Query.dark).matches && 'dark' + || 'no-preference'; + } +} + +/* eslint-disable-next-line @typescript-eslint/no-namespace */ +export namespace PrefersColorSchemeMediaQuery { + export type Value = 'light' | 'dark' | 'no-preference'; + export enum Query { + 'light' = 'screen and (prefers-color-scheme: light)', + 'dark' = 'screen and (prefers-color-scheme: dark)', + 'noPreferences' = 'screen and (prefers-color-scheme: light)', + } +} diff --git a/packages/devui-theme/src/theme/public-api.ts b/packages/devui-theme/src/theme/public-api.ts new file mode 100644 index 0000000000..e6396e2502 --- /dev/null +++ b/packages/devui-theme/src/theme/public-api.ts @@ -0,0 +1,5 @@ +export * from './theme'; +export * from './theme-management'; +export * from './theme-data'; +export * from './theme-service'; +export * from './utils/index'; diff --git a/packages/devui-theme/src/theme/theme-data.ts b/packages/devui-theme/src/theme/theme-data.ts new file mode 100644 index 0000000000..cd82585e08 --- /dev/null +++ b/packages/devui-theme/src/theme/theme-data.ts @@ -0,0 +1,324 @@ +import { Theme } from './theme'; +export const devuiLightTheme: Theme = new Theme({ + id: 'devui-light-theme', + name: 'Light Mode', + cnName: '浅色主题', + data: { + // 基础变量 + 'devui-global-bg': '#f3f6f8', + 'devui-global-bg-normal': '#ffffff', + 'devui-base-bg': '#ffffff', + 'devui-base-bg-dark': '#333854', + 'devui-brand': '#5e7ce0', + 'devui-brand-foil': '#859bff', + 'devui-brand-hover': '#7693f5', + 'devui-brand-active': '#526ecc', + 'devui-brand-active-focus': '#344899', + 'devui-contrast': '#c7000b', + 'devui-text': '#252b3a', + 'devui-text-weak': '#575d6c', + 'devui-aide-text': '#8a8e99', + 'devui-aide-text-stress': '#575d6c', + 'devui-placeholder': '#8a8e99', + 'devui-light-text': '#ffffff', + 'devui-dark-text': '#252b3a', + 'devui-link': '#526ecc', + 'devui-link-active': '#526ecc', + 'devui-link-light': '#96adfa', + 'devui-link-light-active': '#beccfa', + 'devui-line': '#adb0b8', + 'devui-dividing-line': '#dfe1e6', + 'devui-block': '#ffffff', + 'devui-area': '#f8f8f8', + 'devui-danger': '#f66f6a', + 'devui-warning': '#fac20a', + 'devui-waiting': '#9faad7', + 'devui-success': '#50d4ab', + 'devui-info': '#5e7ce0', + 'devui-initial': '#e9edfa', + 'devui-unavailable': '#f5f5f6', + 'devui-shadow': 'rgba(0, 0, 0, 0.2)', + 'devui-light-shadow': 'rgba(0, 0, 0, 0.1)', + // 图标 + 'devui-icon-text': '#252b3a', + 'devui-icon-bg': '#ffffff', + 'devui-icon-fill': '#d3d5d9', + 'devui-icon-fill-hover': '#adb5ce', + 'devui-icon-fill-active': '#5e7ce0', + 'devui-icon-fill-active-hover': '#526ecc', + // 表单 + 'devui-form-control-line': '#adb0b8', + 'devui-form-control-line-hover': '#575d6c', + 'devui-form-control-line-active': '#5e7ce0', + 'devui-form-control-line-active-hover': '#344899', + 'devui-list-item-active-bg': '#5e7ce0', + 'devui-list-item-active-text': '#ffffff', + 'devui-list-item-active-hover-bg': '#526ecc', + 'devui-list-item-hover-bg': '#f2f5fc', + 'devui-list-item-hover-text': '#526ecc', + 'devui-list-item-selected-bg': '#e9edfa', + 'devui-list-item-strip-bg': '#f2f5fc', + // 禁用 + 'devui-disabled-bg': '#f5f5f6', + 'devui-disabled-line': '#dfe1e6', + 'devui-disabled-text': '#adb0b8', + 'devui-primary-disabled': '#beccfa', + 'devui-icon-fill-active-disabled': '#beccfa', + // 特殊背景色 + 'devui-label-bg': '#eef0f5', + 'devui-connected-overlay-bg': '#ffffff', + 'devui-connected-overlay-line': '#526ecc', + 'devui-fullscreen-overlay-bg': '#ffffff', + 'devui-feedback-overlay-bg': '#464d6e', + 'devui-feedback-overlay-text': '#dfe1e6', + 'devui-embed-search-bg': '#f2f5fc', + 'devui-embed-search-bg-hover': '#eef0f5', + 'devui-float-block-shadow': 'rgba(94, 124, 224, 0.3)', + 'devui-highlight-overlay': 'rgba(255, 255, 255, 0.8)', + 'devui-range-item-hover-bg': '#e9edfa', + // 按钮 + 'devui-primary': '#5e7ce0', + 'devui-primary-hover': '#7693f5', + 'devui-primary-active': '#344899', + 'devui-contrast-hover': '#d64a52', + 'devui-contrast-active': '#b12220', + // 状态 + 'devui-danger-line': '#f66f6a', + 'devui-danger-bg': '#ffeeed', + 'devui-warning-line': '#fa9841', + 'devui-warning-bg': '#fff3e8', + 'devui-info-line': '#5e7ce0', + 'devui-info-bg': '#f2f5fc', + 'devui-success-line': '#50d4ab', + 'devui-success-bg': '#edfff9', + 'devui-primary-line': '#5e7ce0', + 'devui-primary-bg': '#f2f5fc', + 'devui-default-line': '#5e7ce0', + 'devui-default-bg': '#f3f6f8', + // 字体设置相关 + 'devui-font-size': '12px', + 'devui-font-size-card-title': '14px', + 'devui-font-size-page-title': '16px', + 'devui-font-size-modal-title': '18px', + 'devui-font-size-price': '20px', + 'devui-font-size-data-overview': '24px', + 'devui-font-size-icon': '16px', + 'devui-font-size-sm': '12px', + 'devui-font-size-md': '12px', + 'devui-font-size-lg': '14px', + 'devui-font-title-weight': 'bold', + 'devui-font-content-weight': 'normal', + 'devui-line-height-base': '1.5', + // 圆角 + 'devui-border-radius': '2px', + 'devui-border-radius-feedback': '4px', + 'devui-border-radius-card': '6px', + // 阴影 + 'devui-shadow-length-base': '0 1px 4px 0', + 'devui-shadow-length-slide-left': '-2px 0 8px 0', + 'devui-shadow-length-slide-right': '2px 0 8px 0', + 'devui-shadow-length-connected-overlay': '0 2px 8px 0', + 'devui-shadow-length-hover': '0 4px 16px 0', + 'devui-shadow-length-feedback-overlay': '0 4px 16px 0', + 'devui-shadow-fullscreen-overlay': '0 8px 40px 0', + // 动效 + 'devui-animation-duration-slow': '300ms', + 'devui-animation-duration-base': '200ms', + 'devui-animation-duration-fast': '100ms', + 'devui-animation-ease-in': 'cubic-bezier(0.5, 0, 0.84, 0.25)', + 'devui-animation-ease-out': 'cubic-bezier(0.16, 0.75, 0.5, 1)', + 'devui-animation-ease-in-out': 'cubic-bezier(0.5, 0.05, 0.5, 0.95)', + 'devui-animation-ease-in-out-smooth': 'cubic-bezier(0.645, 0.045, 0.355, 1)', + 'devui-animation-linear': 'cubic-bezier(0, 0, 1, 1)', + // zIndex + 'devui-z-index-full-page-overlay': '1080', + 'devui-z-index-pop-up': '1060', + 'devui-z-index-dropdown': '1052', + 'devui-z-index-modal': '1050', + 'devui-z-index-drawer': '1040', + 'devui-z-index-framework': '1000' + }, + isDark: false, +}); +export const devuiGreenTheme: Theme = new Theme({ + id: 'devui-green-theme', + name: 'Green - Light Mode', + cnName: '绿色主题', + data: { ...devuiLightTheme.data, 'devui-global-bg': '#f3f8f7', + 'devui-brand': '#3DCCA6', + 'devui-brand-foil': '#7fdac1', + 'devui-brand-hover': '#6DDEBB', + 'devui-brand-active': '#07c693', + 'devui-brand-active-focus': '#369676', + 'devui-link': '#07c693', + 'devui-link-active': '#07c693', + 'devui-link-light': '#96fac8', + 'devui-link-light-active': '#befade', + 'devui-info': '#079CCD', + 'devui-initial': '#CCCCCC', + 'devui-icon-fill-active': '#3DCCA6', + 'devui-icon-fill-active-hover': '#07c693', + 'devui-form-control-line-active': '#3DCCA6', + 'devui-form-control-line-active-hover': '#2EB28A', + 'devui-list-item-active-bg': '#3DCCA6', + 'devui-list-item-active-hover-bg': '#07c693', + 'devui-list-item-hover-bg': '#f3fef9', + 'devui-list-item-hover-text': '#07c693', + 'devui-list-item-selected-bg': '#f3fef9', + 'devui-list-item-strip-bg': '#f3fef9', + 'devui-connected-overlay-line': '#07c693', + 'devui-embed-search-bg': '#f3fef9', + 'devui-float-block-shadow': 'rgba(94, 224, 181, 0.3)', + 'devui-primary': '#3DCCA6', + 'devui-primary-hover': '#6DDEBB', + 'devui-primary-active': '#369676', + 'devui-info-line': '#0486b1', + 'devui-info-bg': '#e3f0f5', + 'devui-success-line': '#50d492', + 'devui-success-bg': '#edfff9', + 'devui-primary-line': '#3DCCA6', + 'devui-primary-bg': '#f3fef9', + 'devui-default-line': '#3DCCA6', + 'devui-default-bg': '#f3f8f7', + 'devui-primary-disabled': '#c5f0e5', + 'devui-icon-fill-active-disabled': '#c5f0e5', + 'devui-range-item-hover-bg': '#d8f9ea',}, + extends: 'devui-light-theme', + isDark: false, +}); +export const devuiDarkTheme: Theme = new Theme({ + id: 'devui-dark-theme', + name: 'Dark Mode', + cnName: '深色主题', + data: { + 'devui-global-bg': '#202124', + 'devui-global-bg-normal': '#202124', + 'devui-base-bg': '#2E2F31', + 'devui-base-bg-dark': '#2e2f31', + 'devui-brand': '#5e7ce0', + 'devui-brand-foil': '#313a61', + 'devui-brand-hover': '#425288', + 'devui-brand-active': '#526ecc', + 'devui-brand-active-focus': '#344899', + 'devui-contrast': '#C7000B', + 'devui-text': '#E8E8E8', + 'devui-text-weak': '#A0A0A0', + 'devui-aide-text': '#909090', + 'devui-aide-text-stress': '#A0A0A0', + 'devui-placeholder': '#8A8A8A', + 'devui-light-text': '#ffffff', + 'devui-dark-text': '#252b3a', + 'devui-link': '#526ECC', + 'devui-link-active': '#344899', + 'devui-link-light': '#96adfa', + 'devui-link-light-active': '#beccfa', + 'devui-line': '#505153', + 'devui-dividing-line': '#3D3E40', + 'devui-block': '#606061', + 'devui-area': '#34363A', + 'devui-danger': '#f66f6a', + 'devui-warning': '#fac20a', + 'devui-waiting': '#5e6580', + 'devui-success': '#50d4ab', + 'devui-info': '#5e7ce0', + 'devui-initial': '#64676e', + 'devui-unavailable': '#5b5b5c', + 'devui-shadow': 'rgba(17, 18, 19, 0.4)', + 'devui-light-shadow': 'rgba(17, 18, 19, 0.5)', + // 图标 + 'devui-icon-text': '#E8E8E8', + 'devui-icon-bg': '#2E2F31', + 'devui-icon-fill': '#606061', + 'devui-icon-fill-hover': '#73788a', + 'devui-icon-fill-active': '#5e7ce0', + 'devui-icon-fill-active-hover': '#526ecc', + // 表单 + 'devui-form-control-line': '#505153', + 'devui-form-control-line-hover': '#909090', + 'devui-form-control-line-active': '#5e7ce0', + 'devui-form-control-line-active-hover': '#344899', + 'devui-list-item-active-bg': '#5e7ce0', + 'devui-list-item-active-text': '#ffffff', + 'devui-list-item-active-hover-bg': '#526ecc', + 'devui-list-item-hover-bg': '#383838', + 'devui-list-item-hover-text': '#526ecc', + 'devui-list-item-selected-bg': '#454545', + 'devui-list-item-strip-bg': '#383838', + // 禁用 + 'devui-disabled-bg': '#3D3E44', + 'devui-disabled-line': '#505153', + 'devui-disabled-text': '#7D7D7D', + 'devui-primary-disabled': '#2B3458', + 'devui-icon-fill-active-disabled': '#2B3458', + // 特殊背景色 + 'devui-label-bg': '#46443F', + 'devui-connected-overlay-bg': '#2F2F2F', + 'devui-connected-overlay-line': '#526ecc', + 'devui-fullscreen-overlay-bg': '#2E2F31', + 'devui-feedback-overlay-bg': '#4C4C4C', + 'devui-feedback-overlay-text': '#DFE1E6', + 'devui-embed-search-bg': '#383838', + 'devui-embed-search-bg-hover': '#3D3E40', + 'devui-float-block-shadow': 'rgba(94, 124, 224, 0.3)', + 'devui-highlight-overlay': 'rgba(255, 255, 255, 0.1)', + 'devui-range-item-hover-bg': '#454545', + // 按钮 + 'devui-primary': '#5e7ce0', + 'devui-primary-hover': '#425288', + 'devui-primary-active': '#344899', + 'devui-contrast-hover': '#D64A52', + 'devui-contrast-active': '#B12220', + // 状态 + 'devui-danger-line': '#985C5A', + 'devui-danger-bg': '#4B3A39', + 'devui-warning-line': '#8D6138', + 'devui-warning-bg': '#554434', + 'devui-info-line': '#546BB7', + 'devui-info-bg': '#383D4F', + 'devui-success-line': '#5D887D', + 'devui-success-bg': '#304642', + 'devui-primary-line': '#546BB7', + 'devui-primary-bg': '#383D4F', + 'devui-default-line': '#5e7ce0', + 'devui-default-bg': '#383838', + }, + extends: 'devui-light-theme', + isDark: true, +}); +export const devuiGreenDarkTheme: Theme = new Theme({ + id: 'devui-green-dark-theme', + name: 'Green - Dark Mode', + cnName: '绿色深色主题', + data: { ...devuiDarkTheme.data, 'devui-brand': '#3DCCA6', + 'devui-brand-foil': '#395e54', + 'devui-brand-hover': '#4c9780', + 'devui-brand-active': '#07c693', + 'devui-brand-active-focus': '#297058', + 'devui-link': '#07c693', + 'devui-link-active': '#08a57b', + 'devui-info': '#046788', + 'devui-initial': '#64676e', + 'devui-icon-fill-active': '#3DCCA6', + 'devui-icon-fill-active-hover': '#07c693', + 'devui-form-control-line-active': '#3DCCA6', + 'devui-form-control-line-active-hover': '#297058', + 'devui-list-item-active-bg': '#3DCCA6', + 'devui-list-item-active-hover-bg': '#07c693', + 'devui-list-item-hover-text': '#07c693', + 'devui-connected-overlay-line': '#07c693', + 'devui-embed-search-bg': '#3f4241', + 'devui-float-block-shadow': 'rgba(94, 224, 181, 0.3)', + 'devui-primary': '#3DCCA6', + 'devui-primary-hover': '#6DDEBB', + 'devui-primary-active': '#369676', + 'devui-info-line': '#035e7c', + 'devui-info-bg': '#383c3d', + 'devui-primary-line': '#3DCCA6', + 'devui-primary-bg': '#3f4241', + 'devui-default-line': '#3DCCA6', + 'devui-default-bg': '#383838', + 'devui-primary-disabled': '#28544B', + 'devui-icon-fill-active-disabled': '#28544B',}, + extends: 'devui-dark-theme', + isDark: true, +}); diff --git a/packages/devui-theme/src/theme/theme-management.ts b/packages/devui-theme/src/theme/theme-management.ts new file mode 100644 index 0000000000..91d34b4258 --- /dev/null +++ b/packages/devui-theme/src/theme/theme-management.ts @@ -0,0 +1,105 @@ +import cssVars from 'css-vars-ponyfill'; +import { Subscription } from 'rxjs'; +import { THEME_KEY } from './key-config'; +import { Theme } from './theme'; +import { devuiDarkTheme, devuiLightTheme } from './theme-data'; +import { ThemeService } from './theme-service'; +import { EventBus } from './utils'; + +/** + * usage + * main.ts + ```ts + import { ThemeServiceInit } from 'devui-theme'; + ThemeServiceInit(); + ``` + * +*/ +export function ThemeServiceInit( + themes?: { [themeName: string]: Theme; }, + defaultThemeName?: string, + extraData?: { + [themeName: string]: { + appendClasses?: Array + cssVariables?: { + [cssVarName: string]: string + } + } + }, + ieSupport = false, // TODO:css-var-ponyflll 仍有一些问题待定位 + allowDynamicTheme = false +) { + if (typeof window === 'undefined') { + return null; + } + + window[THEME_KEY.themeCollection] = themes || { + 'devui-light-theme': devuiLightTheme, + 'devui-dark-theme': devuiDarkTheme, + }; + window[THEME_KEY.currentTheme] = defaultThemeName || 'devui-light-theme'; + const eventBus = window['globalEventBus'] || new EventBus(); // window.globalEventBus 为 框架的事件总线 + const themeService = new ThemeService(eventBus); + window[THEME_KEY.themeService] = themeService; + + themeService.setExtraData(extraData || { + 'devui-dark-theme': { + appendClasses: ['dark-mode'] + } + }); + themeService.initializeTheme(null, allowDynamicTheme); + if (ieSupport) { + ieSupportCssVar(); + } + return themeService; +} + +export function ThemeServiceFollowSystemOn(themeConfig?: { lightThemeName: string; darkThemeName: string; }): Subscription { + if (typeof window === 'undefined') { + return null; + } + + const themeService: ThemeService = window[THEME_KEY.themeService]; + themeService.registerMediaQuery(); + return themeService.mediaQuery.prefersColorSchemeChange.subscribe(value => { + if (value === 'dark') { + themeService.applyTheme(window[THEME_KEY.themeCollection][themeConfig && themeConfig.darkThemeName || 'devui-dark-theme']); + } else { + themeService.applyTheme(window[THEME_KEY.themeCollection][themeConfig && themeConfig.lightThemeName || 'devui-light-theme']); + } + }); +} +export function ThemeServiceFollowSystemOff(sub?: Subscription) { + if (typeof window === 'undefined') { + return null; + } + + if (sub) { + sub.unsubscribe(); + } + const themeService = window[THEME_KEY.themeService]; + themeService.unregisterMediaQuery(); +} + +export function ieSupportCssVar() { + if (typeof window === 'undefined') { + return null; + } + + const isNativeSupport = window['CSS'] && CSS.supports && CSS.supports('(--a: 0)') || false; + if (isNativeSupport) { return; } + cssVars({ watch: true, silent: true }); + const observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + cssVars({ watch: false, silent: true }); + cssVars({ watch: true, silent: true }); + }); + }); + + const config = { attributes: true, attributeFilter: [THEME_KEY.uiThemeAttributeName] }; + + observer.observe(document.querySelector(`#${THEME_KEY.styleElementId}`), config); +} + +// TODO: management should handle add / remove theme from theme collection. +// TODO: move global variables(window.xxxx) to single namespace diff --git a/packages/devui-theme/src/theme/theme-service.ts b/packages/devui-theme/src/theme/theme-service.ts new file mode 100644 index 0000000000..8fcec9c922 --- /dev/null +++ b/packages/devui-theme/src/theme/theme-service.ts @@ -0,0 +1,189 @@ +import { THEME_KEY } from './key-config'; +import { PrefersColorSchemeMediaQuery } from './media-query'; +import { Theme } from './theme'; +import { ContextService, EventBus, IContextService, IEventBus, IStorageService, StorageService } from './utils/index'; + +/** + * 负责CSS变量主题的装卸,主题元数据转换成主题数据 + */ +export class ThemeService { + eventBus: IEventBus; + storage: IStorageService; + context: IContextService; + currentTheme: Theme; + contentElement: HTMLStyleElement; + colorTransitionElement: HTMLStyleElement; + extraData: { + [themeId: string]: { + cssVariables?: { + [varname: string]: string + } + appendClasses?: Array + } + }; + private _appendedClasses: Array; + set appendClasses(classes: Array) { + if (this._appendedClasses) { + this.removeAppendedClass(this._appendedClasses); + } + if (classes) { + this.addAppendClass(classes); + } + this._appendedClasses = classes; + } + + get appendClasses() { + return this._appendedClasses; + } + + public mediaQuery: PrefersColorSchemeMediaQuery; + + constructor(eventBus?: IEventBus, storage?: IStorageService, context?: IContextService) { + this.eventBus = eventBus === undefined ? new EventBus() : eventBus; + this.storage = storage === undefined ? new StorageService() : storage; + this.context = context === undefined ? new ContextService() : context; + } + + initializeTheme(specificThemeId?: string, allowDynamicTheme?: boolean) { + const themeId = specificThemeId + || this.storage.tryGetLocalStorage(THEME_KEY.userLastPreferTheme) + || this.context.getDataFromNameSpace(THEME_KEY.currentTheme); + let theme; + + if (themeId) { + const themes = this.context.getDataFromNameSpace(THEME_KEY.themeCollection); + if (themes && Object.keys(themes).length > 0) { + theme = themes[themeId]; + } + } + this.currentTheme = theme || { + id: 'empty-theme', + name: '', + data: {} + }; + this.createColorTransition(); + if (!theme && allowDynamicTheme) { + return; + } + this.applyTheme(this.currentTheme); + } + + formatCSSVariables(themeData: Theme['data']) { + return Object.keys(themeData).map( + cssVar => ('--' + cssVar + ':' + themeData[cssVar]) + ).join(';'); + } + + applyTheme(theme: Theme) { + this.addColorTransition(); + this.currentTheme = theme; + if (!this.contentElement) { + const styleElement = document.getElementById(THEME_KEY.styleElementId); + if (styleElement) { + this.contentElement = styleElement; + } else { + this.contentElement = document.createElement('style'); + this.contentElement.id = THEME_KEY.styleElementId; + document.head.appendChild(this.contentElement); + } + + } + this.contentElement.innerText = ':root { ' + this.formatCSSVariables(theme.data) + ' }'; + this.contentElement.setAttribute(THEME_KEY.uiThemeAttributeName, this.currentTheme.id); + document.body.setAttribute(THEME_KEY.uiThemeAttributeName, this.currentTheme.id); + + // 用于挂载额外变量和类名 + this.applyExtraData(); + this.saveCustomTheme(this.currentTheme); + + // 通知外部主题变更 + this.notify(theme, 'themeChanged'); + setTimeout(() => {this.removeColorTransition(); }, 500); + } + + saveCustomTheme(customTheme: Theme) { + this.storage.trySetLocalStorage(THEME_KEY.userLastPreferTheme, customTheme.id); + this.storage.trySetLocalStorage(THEME_KEY.userLastPreferThemeData, JSON.stringify(customTheme.data)); + this.context.setDataFromNameSpace(THEME_KEY.currentTheme, customTheme.id); + } + + private notify(theme: Theme, eventType: string) { + if (!this.eventBus) { return; } + this.eventBus.trigger(eventType, theme); + } + + setEventBus(eb: IEventBus) { + this.eventBus = eb; + } + + private addAppendClass(classNames: Array) { + document.body.classList.add(...classNames); + } + + private removeAppendedClass(classNames: Array) { + document.body.classList.remove(...classNames); + } + + setExtraData(data, apply = false) { + this.extraData = data; + if (apply) { + this.applyExtraData(); + } + } + + private applyExtraData() { + const theme = this.currentTheme; + if (this.extraData && this.extraData[theme.id] && this.extraData[theme.id].cssVariables) { + this.contentElement.innerText + = ':root { ' + this.formatCSSVariables(theme.data) + ' }' + + ':root { ' + this.formatCSSVariables(this.extraData[theme.id].cssVariables) + ' }'; + } + if (this.extraData && this.extraData[theme.id] && this.extraData[theme.id].appendClasses) { + this.appendClasses = this.extraData[theme.id].appendClasses; + } else { + this.appendClasses = undefined; + } + } + + public unloadTheme() { + if (this.contentElement && document.contains(this.contentElement)) { + this.contentElement.parentElement.removeChild(this.contentElement); + } + if (this.appendClasses) { + this.appendClasses = undefined; + } + } + + public registerMediaQuery() { + if (!this.mediaQuery) { + this.mediaQuery = new PrefersColorSchemeMediaQuery(); + } + this.mediaQuery.register(); + } + + public unregisterMediaQuery() { + if (!this.mediaQuery) { + return; + } + this.mediaQuery.unregister(); + this.mediaQuery = undefined; + } + + private createColorTransition() { + this.colorTransitionElement = document.createElement('style'); + this.colorTransitionElement.id = THEME_KEY.transitionStyleElementId; + this.colorTransitionElement.innerText = ` + * { transition: background .3s ease-out, background-color .3s ease-out, + border .3s ease-out, border-color .3s ease-out, + box-shadow .3s ease-out, box-shadow-color .3s ease-out} + `; + } + + private addColorTransition() { + document.head.appendChild(this.colorTransitionElement); + } + private removeColorTransition() { + if (!this.colorTransitionElement.parentElement) {return; } + this.colorTransitionElement.parentElement.removeChild(this.colorTransitionElement); + } +} diff --git a/packages/devui-theme/src/theme/theme.ts b/packages/devui-theme/src/theme/theme.ts new file mode 100644 index 0000000000..977192b188 --- /dev/null +++ b/packages/devui-theme/src/theme/theme.ts @@ -0,0 +1,43 @@ +export type ThemeId = string; + +export class Theme { + id: ThemeId; + name: string; + cnName?: string; + data: { + [cssVarName: string]: string + }; + extends?: ThemeId; + isDark?: boolean; + isPreview?: boolean; + isExtendable?: boolean; + extra?: { + appendClass?: Array + cssVariables?: { + [cssVarName: string]: string + } + [prop: string]: any + } | any; + + constructor(theme: { + id: ThemeId + name: string + cnName?: string + data: { + [cssVarName: string]: string + } + extends?: ThemeId + isDark?: boolean + isPreview?: boolean + isExtendable?: boolean + }) { + this.id = theme.id; + this.name = theme.name; + this.cnName = theme.cnName || this.name; + this.data = theme.data; + this.extends = theme.extends || null; + this.isDark = theme.isDark || undefined; + this.isPreview = theme.isPreview || false; + this.isExtendable = theme.isExtendable || true; + } +} diff --git a/packages/devui-theme/src/theme/utils/context-service.ts b/packages/devui-theme/src/theme/utils/context-service.ts new file mode 100644 index 0000000000..577f966843 --- /dev/null +++ b/packages/devui-theme/src/theme/utils/context-service.ts @@ -0,0 +1,17 @@ +import { IContextService } from './interface'; +export class ContextService implements IContextService { + getDataFromNameSpace(nameSpace: string) { + if (typeof window === 'undefined') { + return null; + } + + return window[nameSpace]; + } + setDataFromNameSpace(nameSpace: string, value: any) { + if (typeof window === 'undefined') { + return; + } + + window[nameSpace] = value; + } +} diff --git a/packages/devui-theme/src/theme/utils/event-bus.ts b/packages/devui-theme/src/theme/utils/event-bus.ts new file mode 100644 index 0000000000..0eb9218e4f --- /dev/null +++ b/packages/devui-theme/src/theme/utils/event-bus.ts @@ -0,0 +1,87 @@ +import { IEventBus } from './interface'; + +export class EventBus implements IEventBus { + private eventBusCore = []; + private areFuncEqual(a, b) { + return a.toString() === b.toString(); + } + private isKeyValueObjInArr(arr, key, val) { + const filteredArr = arr.filter(entry => { + return entry[key] === val; + }); + return filteredArr.length > 0; + } + private removeFuncInFuncArr(arr, fn) { + for (let z = 0; z < arr.length; z++) { + if (this.areFuncEqual(arr[z], fn)) { + arr.splice(z, 1); + } + } + return arr; + } + private getKeyValueObjInArr(arr, key, val) { + const filteredArr = arr.filter(entry => { + return entry[key] === val; + }); + return filteredArr[0]; + } + private addEvent(eventName, eventFunc) { + if (!this.isKeyValueObjInArr(this.eventBusCore, 'eventName', eventName)) { + this.eventBusCore.push({ eventName: eventName, eventFuncArr: [eventFunc] }); + } else { + this.eventBusCore = this.eventBusCore.map(event => { + if (event['eventName'] === eventName) { + event.eventFuncArr.push(eventFunc); + } + return event; + }); + } + } + public add(eventName, callbacks) { + if (!eventName) { + return; + } + if (typeof callbacks === 'function') { + for (let i = 1; i < arguments.length; i++) { + this.addEvent(eventName, arguments[i]); + } + } + if (typeof callbacks === 'object' && callbacks.forEach) { + callbacks.forEach(fn => { + this.addEvent(eventName, fn); + }); + } + } + public remove(eventName, callbacks) { + if (!eventName) { + return; + } + for (let i = 0; i < this.eventBusCore.length; i++) { + if (this.eventBusCore[i].eventName === eventName) { + if (arguments.length === 1) { + return this.eventBusCore.splice(i, 1); + } + const removedEvent = this.eventBusCore.splice(i, 1)[0]; + if (typeof callbacks === 'function') { + for (let k = 1; k < arguments.length; k++) { + removedEvent.eventFuncArr = this.removeFuncInFuncArr(removedEvent.eventFuncArr, arguments[k]); + } + } + if (typeof callbacks === 'object' && callbacks.length) { + for (let x = 0; x < callbacks.length; x++) { + removedEvent.eventFuncArr = this.removeFuncInFuncArr(removedEvent.eventFuncArr, callbacks[x]); + } + } + this.eventBusCore.push(removedEvent); + } + } + } + public trigger(eventName, data) { + const event = this.getKeyValueObjInArr(this.eventBusCore, 'eventName', eventName); + if (event) { + (event.eventFuncArr || []).forEach(fn => { + fn.apply(this, data); + }); + } + } +} diff --git a/packages/devui-theme/src/theme/utils/index.ts b/packages/devui-theme/src/theme/utils/index.ts new file mode 100644 index 0000000000..7c70488766 --- /dev/null +++ b/packages/devui-theme/src/theme/utils/index.ts @@ -0,0 +1,4 @@ +export * from './interface'; +export * from './event-bus'; +export * from './context-service'; +export * from './storage-service'; diff --git a/packages/devui-theme/src/theme/utils/interface.ts b/packages/devui-theme/src/theme/utils/interface.ts new file mode 100644 index 0000000000..f10233e9bd --- /dev/null +++ b/packages/devui-theme/src/theme/utils/interface.ts @@ -0,0 +1,15 @@ +export interface IStorageService { + tryGetLocalStorage(key: string): any + trySetLocalStorage(key: string, value: any): void +} + +export interface IContextService { + getDataFromNameSpace(nameSpace: string): any + setDataFromNameSpace(nameSpace: string, value: any): any +} + +export interface IEventBus { + add(eventName: string, callbacks: Function): void + remove(eventName: string, callbacks: Function): void + trigger(eventName: string, data: any): void +} diff --git a/packages/devui-theme/src/theme/utils/storage-service.ts b/packages/devui-theme/src/theme/utils/storage-service.ts new file mode 100644 index 0000000000..773c704069 --- /dev/null +++ b/packages/devui-theme/src/theme/utils/storage-service.ts @@ -0,0 +1,17 @@ +import { IStorageService } from './interface'; +export class StorageService implements IStorageService { + tryGetLocalStorage(key: string) { + if (typeof window === 'undefined') { + return null; + } + + return window.localStorage.getItem(key); + } + trySetLocalStorage(key: string, value: any) { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(key, value); + } +} diff --git a/packages/devui-theme/tsconfig.json b/packages/devui-theme/tsconfig.json new file mode 100644 index 0000000000..af31eb8152 --- /dev/null +++ b/packages/devui-theme/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["esnext", "dom"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/devui-theme/tsconfig.node.json b/packages/devui-theme/tsconfig.node.json new file mode 100644 index 0000000000..e993792cb1 --- /dev/null +++ b/packages/devui-theme/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "composite": true, + "module": "esnext", + "moduleResolution": "node" + }, + "include": ["vite.config.ts"] +} diff --git a/packages/devui-theme/vite.config.ts b/packages/devui-theme/vite.config.ts new file mode 100644 index 0000000000..d89419e5fb --- /dev/null +++ b/packages/devui-theme/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + publicDir: false, + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'DevuiTheme', + fileName: (format) => `index.${format}.js` + }, + rollupOptions: { + // 确保外部化处理那些你不想打包进库的依赖 + external: ['vue'], + output: { + // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量 + globals: { + vue: 'Vue' + } + } + } + } +}) diff --git a/packages/devui-vue/.eslintrc.js b/packages/devui-vue/.eslintrc.js deleted file mode 100644 index 89aa01109a..0000000000 --- a/packages/devui-vue/.eslintrc.js +++ /dev/null @@ -1,48 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - sourceType: 'module', - ecmaVersion: 6, - ecmaFeatures: { - jsx: true, - tsx: true - } - }, - env: { - browser: true, - node: true, - jest: true, - es6: true - }, - plugins: ['@typescript-eslint'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:vue/vue3-recommended', - 'plugin:import/recommended', - 'plugin:import/typescript' - ], - rules: { - quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }], - 'no-undef': 2, - 'vue/max-attributes-per-line': 'off', - 'vue/no-multiple-template-root': 'off', - 'vue/script-setup-uses-vars': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/member-delimiter-style': [ - 'error', - { - multiline: { - delimiter: 'none', - requireLast: false - }, - singleline: { - delimiter: 'semi', - requireLast: true - } - } - ], - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['error'] - } -} diff --git a/packages/devui-vue/.husky/commit-msg b/packages/devui-vue/.husky/commit-msg deleted file mode 100755 index 80e240c9df..0000000000 --- a/packages/devui-vue/.husky/commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -cd ./packages/devui-vue && npx commitlint --edit $1 \ No newline at end of file diff --git a/packages/devui-vue/.husky/pre-commit b/packages/devui-vue/.husky/pre-commit deleted file mode 100755 index 9a45e99d4e..0000000000 --- a/packages/devui-vue/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -cd ./packages/devui-vue && npx @ls-lint/ls-lint && npx lint-staged diff --git a/packages/devui-vue/.yarnrc b/packages/devui-vue/.yarnrc deleted file mode 100644 index f4074f57d7..0000000000 --- a/packages/devui-vue/.yarnrc +++ /dev/null @@ -1 +0,0 @@ -registry "https://registry.npm.taobao.org" \ No newline at end of file diff --git a/packages/devui-vue/LICENSE b/packages/devui-vue/LICENSE deleted file mode 100644 index e3a2b0425c..0000000000 --- a/packages/devui-vue/LICENSE +++ /dev/null @@ -1,23 +0,0 @@ -MIT License - -Copyright (c) 2019 - present DevUI. -Copyright (c) 2019 - present Huawei Technologies Co., Ltd. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/packages/devui-vue/dc.config.ts b/packages/devui-vue/dc.config.ts new file mode 100644 index 0000000000..6b5ac6d109 --- /dev/null +++ b/packages/devui-vue/dc.config.ts @@ -0,0 +1,10 @@ +import { defineCliConfig } from 'devui-cli'; + +export default defineCliConfig({ + componentRootDir: './devui', + libClassPrefix: 'devui', + libEntryFileName: 'vue-devui', + libEntryRootDir: './devui', + libPrefix: 'D', + libStyleFileSuffix: '.scss' +}) diff --git a/packages/devui-vue/devui-cli/commands/build.js b/packages/devui-vue/devui-cli/commands/build.js index 1795406945..5e0ae1ddb3 100644 --- a/packages/devui-vue/devui-cli/commands/build.js +++ b/packages/devui-vue/devui-cli/commands/build.js @@ -1,28 +1,28 @@ -const path = require('path') -const fs = require('fs') -const fsExtra = require('fs-extra') -const { defineConfig, build } = require('vite') -const vue = require('@vitejs/plugin-vue') -const vueJsx = require('@vitejs/plugin-vue-jsx') -const nuxtBuild = require('./build-nuxt-auto-import') +const path = require('path'); +const fs = require('fs'); +const fsExtra = require('fs-extra'); +const { defineConfig, build } = require('vite'); +const vue = require('@vitejs/plugin-vue'); +const vueJsx = require('@vitejs/plugin-vue-jsx'); +const nuxtBuild = require('./build-nuxt-auto-import'); -const entryDir = path.resolve(__dirname, '../../devui') -const outputDir = path.resolve(__dirname, '../../build') +const entryDir = path.resolve(__dirname, '../../devui'); +const outputDir = path.resolve(__dirname, '../../build'); const baseConfig = defineConfig({ configFile: false, publicDir: false, - plugins: [vue(), vueJsx()] -}) + plugins: [vue(), vueJsx()], +}); const rollupOptions = { - external: ['vue', 'vue-router'], + external: ['vue', 'vue-router', '@vueuse/core', '@floating-ui/dom'], output: { globals: { - vue: 'Vue' - } - } -} + vue: 'Vue', + }, + }, +}; const buildSingle = async (name) => { await build( @@ -34,13 +34,13 @@ const buildSingle = async (name) => { entry: path.resolve(entryDir, name), name: 'index', fileName: 'index', - formats: ['es', 'umd'] + formats: ['es', 'umd'], }, - outDir: path.resolve(outputDir, name) - } + outDir: path.resolve(outputDir, name), + }, }) - ) -} + ); +}; const buildAll = async () => { await build( @@ -50,15 +50,15 @@ const buildAll = async () => { rollupOptions, lib: { entry: path.resolve(entryDir, 'vue-devui.ts'), - name: 'vue-devui', + name: 'VueDevui', fileName: 'vue-devui', - formats: ['es', 'umd'] + formats: ['es', 'umd'], }, - outDir: outputDir - } + outDir: outputDir, + }, }) - ) -} + ); +}; const createPackageJson = (name) => { const fileStr = `{ @@ -67,25 +67,25 @@ const createPackageJson = (name) => { "main": "index.umd.js", "module": "index.es.js", "style": "style.css" -}` +}`; - fsExtra.outputFile(path.resolve(outputDir, `${name}/package.json`), fileStr, 'utf-8') -} + fsExtra.outputFile(path.resolve(outputDir, `${name}/package.json`), fileStr, 'utf-8'); +}; exports.build = async () => { - await buildAll() + await buildAll(); const components = fs.readdirSync(entryDir).filter((name) => { - const componentDir = path.resolve(entryDir, name) - const isDir = fs.lstatSync(componentDir).isDirectory() - return isDir && fs.readdirSync(componentDir).includes('index.ts') - }) + const componentDir = path.resolve(entryDir, name); + const isDir = fs.lstatSync(componentDir).isDirectory(); + return isDir && fs.readdirSync(componentDir).includes('index.ts'); + }); for (const name of components) { - await buildSingle(name) - createPackageJson(name) - nuxtBuild.createAutoImportedComponent(name) + await buildSingle(name); + createPackageJson(name); + nuxtBuild.createAutoImportedComponent(name); } - nuxtBuild.createNuxtPlugin() -} + nuxtBuild.createNuxtPlugin(); +}; diff --git a/packages/devui-vue/devui-cli/shared/utils.js b/packages/devui-vue/devui-cli/shared/utils.js index 11fb60c706..0b1f1c4b8e 100644 --- a/packages/devui-vue/devui-cli/shared/utils.js +++ b/packages/devui-vue/devui-cli/shared/utils.js @@ -3,8 +3,8 @@ const { INDEX_FILE_NAME, DEVUI_DIR } = require('./constant') const { resolve } = require('path') const logger = require('./logger') const fs = require('fs-extra') -const traverse = require("@babel/traverse").default -const babelParser = require("@babel/parser") +const traverse = require('@babel/traverse').default +const babelParser = require('@babel/parser') exports.bigCamelCase = (str) => { return upperFirst(camelCase(str)) diff --git a/packages/devui-vue/devui/accordion/src/accordion-item-hreflink.tsx b/packages/devui-vue/devui/accordion/src/accordion-item-hreflink.tsx new file mode 100644 index 0000000000..9ac9501716 --- /dev/null +++ b/packages/devui-vue/devui/accordion/src/accordion-item-hreflink.tsx @@ -0,0 +1,127 @@ +import { defineComponent, toRefs, computed, inject } from 'vue' +import { accordionProps } from './accordion-types' +import { AccordionItemClickEvent, AccordionMenuItem, AccordionLinkableItem } from './accordion.type' +import DAccordionItem from './accordion-item' +import { getRootSlots } from './utils' + +export default defineComponent({ + name: 'DAccordionItemHreflink', + component: { + DAccordionItem + }, + props: { + item: Object as () => AccordionLinkableItem, + deepth: { + type: Number, + default: 0 + }, + parent: { + type: Object as () => AccordionMenuItem, + default: null + }, + ...accordionProps + }, + setup(props) { + const { + item, + deepth, + parent, + titleKey, + linkKey, + linkTargetKey, + linkDefaultTarget, + // activeKey, + disabledKey, + itemTemplate + } = toRefs(props) + + const rootSlots = getRootSlots() + const accordionCtx = inject('accordionContext') as any + + const title = computed(() => { + return item.value && item.value[titleKey.value] + }) + + const link = computed(() => { + return item.value && item.value[linkKey.value] + }) + + // const active = computed(() => { + // return item.value && item.value[activeKey.value] + // }) + + // const childActived = computed(() => { + // return active.value + // }) + + const target = computed(() => { + return item.value && (item.value[linkTargetKey.value] || linkDefaultTarget.value) + }) + + const disabled = computed(() => { + return item.value && item.value[disabledKey.value] + }) + + const parentValue = parent.value + const deepValue = deepth.value + + const linkItemClickFn = (itemEvent: AccordionItemClickEvent) => { + if (item.value && !disabled.value) { + accordionCtx.itemClickFn(itemEvent) + } + } + + const renderContent = () => { + return ( + <> +
+ {(!rootSlots.itemTemplate || itemTemplate.value === false) && <>{title.value}} + {rootSlots.itemTemplate && + itemTemplate.value !== false && + rootSlots.itemTemplate?.({ + parent: parentValue, + deepth: deepValue, + item: item.value + })} + + ) + } + + return () => { + return ( + <> + + + ) + } + } +}) diff --git a/packages/devui-vue/devui/accordion/src/accordion-item-routerlink.tsx b/packages/devui-vue/devui/accordion/src/accordion-item-routerlink.tsx new file mode 100644 index 0000000000..44e55522bb --- /dev/null +++ b/packages/devui-vue/devui/accordion/src/accordion-item-routerlink.tsx @@ -0,0 +1,147 @@ +import { defineComponent, toRefs, computed, inject } from 'vue' +import { useRoute } from 'vue-router' +import { accordionProps } from './accordion-types' +import { AccordionItemClickEvent, AccordionMenuItem, AccordionLinkableItem } from './accordion.type' +import DAccordionItem from './accordion-item' +import { getRootSlots } from './utils' + +export default defineComponent({ + name: 'DAccordionItemRouterlink', + component: { + DAccordionItem + }, + props: { + item: Object as () => AccordionLinkableItem, + deepth: { + type: Number, + default: 0 + }, + parent: { + type: Object as () => AccordionMenuItem, + default: null + }, + ...accordionProps + }, + setup(props) { + const { + item, + deepth, + parent, + titleKey, + linkKey, + linkDefaultTarget, + disabledKey, + itemTemplate + } = toRefs(props) + + const route = useRoute() + const rootSlots = getRootSlots() + const accordionCtx = inject('accordionContext') as any + console.log(useRoute()) + + const title = computed(() => { + return item.value && item.value[titleKey.value] + }) + + const link = computed(() => { + return item.value && item.value[linkKey.value] + }) + + const isUsedVueRouter = computed(() => route !== undefined) + + const routerLinkActive = computed(() => { + return route === link.value + }) + + const disabled = computed(() => { + return item.value && item.value[disabledKey.value] + }) + + const parentValue = parent.value + const deepValue = deepth.value + + const linkItemClickFn = (itemEvent: AccordionItemClickEvent) => { + if (item.value && !disabled.value) { + accordionCtx.itemClickFn(itemEvent) + } + } + + const renderContent = () => { + return ( + <> +
+ {(!rootSlots.itemTemplate || itemTemplate.value === false) && <>{title.value}} + {rootSlots.itemTemplate && + itemTemplate.value !== false && + rootSlots.itemTemplate?.({ + parent: parentValue, + deepth: deepValue, + item: item.value + })} + + ) + } + + return () => { + return ( + <> +
+ {!disabled.value && ( + <> + {isUsedVueRouter.value && ( + // TODO: vue-router解决方案 + + linkItemClickFn({ + item: item.value, + parent: parentValue, + event: e + }) + } + > + {renderContent()} + + )} + {!isUsedVueRouter.value && ( + + linkItemClickFn({ + item: item.value, + parent: parentValue, + event: e + }) + } + > + {renderContent()} + + )} + + )} + {disabled.value && ( + + {renderContent()} + + )} +
+ + ) + } + } +}) diff --git a/packages/devui-vue/devui/accordion/src/accordion-item.tsx b/packages/devui-vue/devui/accordion/src/accordion-item.tsx index 14e98c7acb..a20b8a919d 100644 --- a/packages/devui-vue/devui/accordion/src/accordion-item.tsx +++ b/packages/devui-vue/devui/accordion/src/accordion-item.tsx @@ -31,8 +31,8 @@ export default defineComponent({ const rootSlots = getRootSlots() const accordionCtx = inject('accordionContext') as any - let parentValue = parent.value - let deepValue = deepth.value + const parentValue = parent.value + const deepValue = deepth.value const disabled = computed(() => { return item.value && item.value[disabledKey.value] diff --git a/packages/devui-vue/devui/accordion/src/accordion-list.tsx b/packages/devui-vue/devui/accordion/src/accordion-list.tsx index 4852054957..e079dcc030 100644 --- a/packages/devui-vue/devui/accordion/src/accordion-list.tsx +++ b/packages/devui-vue/devui/accordion/src/accordion-list.tsx @@ -1,22 +1,21 @@ -import { - computed, - defineComponent, - inject, - toRefs -} from 'vue' +import { computed, defineComponent, inject, toRefs } from 'vue' import type { AccordionMenuItem } from './accordion.type' import DAccordionMenu from './accordion-menu' import DAccordionItem from './accordion-item' +import DAccordionItemHreflink from './accordion-item-hreflink' +import DAccordionItemRouterlink from './accordion-item-routerlink' import { accordionProps } from './accordion-types' import { getRootSlots } from '../src/utils' export default defineComponent({ name: 'DAccordionList', - inheritAttrs: false, components: { DAccordionMenu, - DAccordionItem + DAccordionItem, + DAccordionItemHreflink, + DAccordionItemRouterlink }, + inheritAttrs: false, props: { data: { type: Array as () => Array, @@ -44,13 +43,14 @@ export default defineComponent({ showNoContent, loadingKey, titleKey, + linkTypeKey, loadingTemplate, noContentTemplate, innerListTemplate } = toRefs(props) - let parentValue = parent.value - let deepValue = deepth.value + const parentValue = parent.value + const deepValue = deepth.value const rootSlots = getRootSlots() @@ -59,15 +59,18 @@ export default defineComponent({ const loading = computed(() => { return parentValue && parentValue[loadingKey.value] }) + const noContent = computed(() => { - let dataValue = data.value + const dataValue = data.value return dataValue === undefined || dataValue === null || dataValue.length === 0 }) return () => { return ( <> - {(!rootSlots.innerListTemplate || deepth.value === 0 || innerListTemplate.value === false) && ( + {(!rootSlots.innerListTemplate || + deepth.value === 0 || + innerListTemplate.value === false) && (
    {data.value.map((item) => { return ( @@ -89,12 +92,60 @@ export default defineComponent({ {/* 普通类型 */} {(!linkType.value || linkType.value === '') && ( )} + {/* 路由链接类型 */} + {linkType.value === 'routerLink' && ( + + )} + {/* 普通链接类型 */} + {linkType.value === 'hrefLink' && ( + + )} + {/* 动态链接类型 */} + {linkType.value === 'dependOnLinkTypeKey' && ( + <> + {item[linkTypeKey.value] === 'routerLink' && ( + + )} + {item[linkTypeKey.value] === 'hrefLink' && ( + + )} + {item[linkTypeKey.value] !== 'routerLink' && + item[linkTypeKey.value] !== 'hrefLink' && ( + + )} + + )} )} @@ -130,7 +181,7 @@ export default defineComponent({ { // 自定义加载 loading.value && - rootSlots.loadingTemplate && + rootSlots.loadingTemplate && loadingTemplate.value !== false && rootSlots.loadingTemplate?.({ item: parentValue, diff --git a/packages/devui-vue/devui/accordion/src/accordion-menu.tsx b/packages/devui-vue/devui/accordion/src/accordion-menu.tsx index a2ca212abc..94d525a751 100644 --- a/packages/devui-vue/devui/accordion/src/accordion-menu.tsx +++ b/packages/devui-vue/devui/accordion/src/accordion-menu.tsx @@ -39,8 +39,8 @@ export default defineComponent({ const rootSlots = getRootSlots() const accordionCtx = inject('accordionContext') as any - let parentValue = parent.value - let deepValue = deepth.value + const parentValue = parent.value + const deepValue = deepth.value const toggle = (itemEvent: AccordionMenuToggleEvent) => { accordionCtx.menuToggleFn(itemEvent) diff --git a/packages/devui-vue/devui/accordion/src/accordion-types.ts b/packages/devui-vue/devui/accordion/src/accordion-types.ts index 2aa7396ccc..a2cf54dacc 100644 --- a/packages/devui-vue/devui/accordion/src/accordion-types.ts +++ b/packages/devui-vue/devui/accordion/src/accordion-types.ts @@ -1,5 +1,5 @@ -import { ExtractPropTypes } from "vue"; -import { AccordionMenuType } from "./accordion.type"; +import { ExtractPropTypes } from 'vue'; +import { AccordionMenuType } from './accordion.type'; export const accordionProps = { data: { @@ -7,12 +7,12 @@ export const accordionProps = { default: null, }, /* Key值定义, 用于自定义数据结构 */ - titleKey: { type: String, default: "title" }, // 标题的key,item[titleKey]类型为string,为标题显示内容 - loadingKey: { type: String, default: "loading" }, // 子菜单动态加载item[loadingKey]类型为boolean - childrenKey: { type: String, default: "children" }, // 子菜单Key - disabledKey: { type: String, default: "disabled" }, // 是否禁用Key - activeKey: { type: String, default: "active" }, // 菜单是否激活/选中 - openKey: { type: String, default: "open" }, // 菜单是否打开 + titleKey: { type: String, default: 'title' }, // 标题的key,item[titleKey]类型为string,为标题显示内容 + loadingKey: { type: String, default: 'loading' }, // 子菜单动态加载item[loadingKey]类型为boolean + childrenKey: { type: String, default: 'children' }, // 子菜单Key + disabledKey: { type: String, default: 'disabled' }, // 是否禁用Key + activeKey: { type: String, default: 'active' }, // 菜单是否激活/选中 + openKey: { type: String, default: 'open' }, // 菜单是否打开 /* 菜单模板 */ menuItemTemplate: { type: Boolean, default: true }, // 可展开菜单内容条模板 @@ -42,21 +42,21 @@ export const accordionProps = { /* 内置路由/链接/动态判断路由或链接类型 */ linkType: { type: String as () => - | "routerLink" - | "hrefLink" - | "dependOnLinkTypeKey" - | "" + | 'routerLink' + | 'hrefLink' + | 'dependOnLinkTypeKey' + | '' | string, - default: "", + default: '', }, - linkTypeKey: { type: String, default: "linkType" }, // linkType为'dependOnLinkTypeKey'时指定对象linkType定义区 - linkKey: { type: String, default: "link" }, // 链接内容的key - linkTargetKey: { type: String, default: "target" }, // 链接目标窗口的key - linkDefaultTarget: { type: String, default: "_self" }, // 不设置target的时候target默认值 + linkTypeKey: { type: String, default: 'linkType' }, // linkType为'dependOnLinkTypeKey'时指定对象linkType定义区 + linkKey: { type: String, default: 'link' }, // 链接内容的key + linkTargetKey: { type: String, default: 'target' }, // 链接目标窗口的key + linkDefaultTarget: { type: String, default: '_self' }, // 不设置target的时候target默认值 accordionType: { - type: String as () => "normal" | "embed", - default: "normal", + type: String as () => 'normal' | 'embed', + default: 'normal', }, } as const; diff --git a/packages/devui-vue/devui/accordion/src/accordion.scss b/packages/devui-vue/devui/accordion/src/accordion.scss index 34a19882a3..44bd109036 100644 --- a/packages/devui-vue/devui/accordion/src/accordion.scss +++ b/packages/devui-vue/devui/accordion/src/accordion.scss @@ -5,10 +5,13 @@ @import '../../style/theme/corner'; @import '../../style/core/animation'; -.devui-accordion-menu ol, ul { - margin: 0 !important; - line-height: 0 !important; - font-size: 12px; +.devui-accordion-menu { + ol, + ul { + margin: 0 !important; + line-height: 0 !important; + font-size: 12px; + } } :host { @@ -118,7 +121,7 @@ d-accordion-item-routerlink { /* 展开图标相关 */ .devui-accordion-menu-item > .devui-accordion-item-title { position: relative; - + & > .devui-accordion-open-icon { display: inline-block; text-indent: 0; @@ -175,8 +178,8 @@ d-accordion-item-routerlink { .devui-accordion-list .devui-accordion-menu-hidden { // display: none; - opacity: 0; - height: 0px; + opacity: 0; + height: 0; overflow: hidden; } diff --git a/packages/devui-vue/devui/accordion/src/composables/use-active.ts b/packages/devui-vue/devui/accordion/src/composables/use-active.ts deleted file mode 100644 index 0ed36c8eaa..0000000000 --- a/packages/devui-vue/devui/accordion/src/composables/use-active.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ref, Ref, toRefs } from 'vue' -import { AccordionProps } from '../accordion-types' -import { AccordionItemClickEvent, AccordionMenuItem } from '../accordion.type' - -import { flatten } from '../utils' - -type activeObjectRef = { - [key: string]: { - isDisable: boolean - isActive: boolean - } -} - -type TypeUseActive = ( - props: AccordionProps, - emit: (arg0: string, arg1: AccordionMenuItem) => void -) => { - activeObjectRef: Ref - prevActiveItemIdRef: Ref - initActiveItem: (item: AccordionMenuItem) => void - activeItemFn: (item: AccordionMenuItem) => void - // itemClickFn: (itemEvent: AccordionItemClickEvent) => void - // linkItemClickFn: (itemEvent: AccordionItemClickEvent) => void -} - -const useActive: TypeUseActive = (props, emit) => { - const { activeKey, disabledKey } = toRefs(props) - const activeObjectRef: Ref = ref({}) - const prevActiveItemIdRef: Ref = ref(null) //记录用户点击的激活菜单项 - - const initActiveItem = (item) => { - activeObjectRef.value[item.id] = { - isDisable: item[disabledKey.value], - isActive: item[activeKey.value] - } - if (item[activeKey.value]) { - prevActiveItemIdRef.value = item.id - } - } - - // 激活子菜单项并去掉其他子菜单的激活 - const activeItemFn = (item: AccordionMenuItem) => { - if (activeObjectRef.value[item.id].isDisable) return - if (prevActiveItemIdRef.value === item.id) return - if (prevActiveItemIdRef.value) { - activeObjectRef.value[prevActiveItemIdRef.value].isActive = false - } - activeObjectRef.value[item.id].isActive = true - prevActiveItemIdRef.value = item.id - emit('activeItemChange', item) - } - - // 点击了可点击菜单 - // const itemClickFn = (itemEvent: AccordionItemClickEvent) => { - // const prevActiveItemIdRef = clickActiveItem - // activeItemFn(itemEvent.item) - // // emit('itemClick', {...itemEvent, prevActiveItem: prevActiveItem}); - // } - - // const linkItemClickFn = (itemEvent: AccordionItemClickEvent) => { - // const prevActiveItem = clickActiveItem - // clickActiveItem.value = itemEvent.item - // // emit('itemClick', {...itemEvent, prevActiveItem: prevActiveItem}); - // } - - return { - activeObjectRef, - prevActiveItemIdRef, - initActiveItem, - activeItemFn - // itemClickFn, - // linkItemClickFn - } -} - -export default useActive diff --git a/packages/devui-vue/devui/accordion/src/composables/use-toggle.ts b/packages/devui-vue/devui/accordion/src/composables/use-toggle.ts deleted file mode 100644 index 646175ef9b..0000000000 --- a/packages/devui-vue/devui/accordion/src/composables/use-toggle.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { toRefs, ref } from 'vue' -import { AccordionMenuItem, AccordionMenuToggleEvent } from '../accordion.type' -import { flatten } from '../utils' - -type TypeMenuOpenStatusRef = { - [key: string]: { - isOpen: boolean - } -} - -const useToggle = (props) => { - const menuOpenStatusRef = ref({}) - const preOpenFoldeRef = ref('') - const { data, childrenKey, openKey, restrictOneOpen } = toRefs(props) - - const initAllOpenData = (item) => { - menuOpenStatusRef.value[item.id] = { - isOpen: item[openKey.value] - } - if (item[openKey.value]) { - preOpenFoldeRef.value = item.id - } - } - - // 打开或关闭一级菜单,如果有限制只能展开一项则关闭其他一级菜单 - const openMenuFn = (item, open) => { - if (open && restrictOneOpen.value) { - if (preOpenFoldeRef.value === item.id) return - if (preOpenFoldeRef.value) { - menuOpenStatusRef.value[preOpenFoldeRef.value].isOpen = false - } - } - menuOpenStatusRef.value[item.id].isOpen = open - } - - // 打开或关闭可折叠菜单 - const menuToggleFn = (menuEvent: AccordionMenuToggleEvent) => { - openMenuFn(menuEvent.item, menuEvent.open) - } - - const cleanOpenData = () => { - flatten(data.value, childrenKey.value, true, false).forEach( - (item) => (item[openKey.value] = undefined) - ) - } - - return { - menuOpenStatusRef, - initAllOpenData, - openMenuFn, - menuToggleFn, - cleanOpenData - } -} - -export default useToggle diff --git a/packages/devui-vue/devui/accordion/src/utils.ts b/packages/devui-vue/devui/accordion/src/utils.ts index 91d03b0360..e4d01bb755 100644 --- a/packages/devui-vue/devui/accordion/src/utils.ts +++ b/packages/devui-vue/devui/accordion/src/utils.ts @@ -1,5 +1,5 @@ import { AccordionMenuItem, AccordionMenuType } from './accordion.type' -import { ComponentInternalInstance, getCurrentInstance } from "vue" +import { ComponentInternalInstance, getCurrentInstance } from 'vue' const flatten = ( arr: Array, diff --git a/packages/devui-vue/devui/alert/src/alert.scss b/packages/devui-vue/devui/alert/src/alert.scss index 68704fc1ac..5b880a12e3 100644 --- a/packages/devui-vue/devui/alert/src/alert.scss +++ b/packages/devui-vue/devui/alert/src/alert.scss @@ -155,7 +155,6 @@ } .devui-alter-close { - height: 0 !important; margin: 0; padding-top: 0; padding-bottom: 0; diff --git a/packages/devui-vue/devui/alert/src/alert.tsx b/packages/devui-vue/devui/alert/src/alert.tsx index afd1b29f97..bb3889e960 100644 --- a/packages/devui-vue/devui/alert/src/alert.tsx +++ b/packages/devui-vue/devui/alert/src/alert.tsx @@ -36,24 +36,22 @@ export default defineComponent({ const hide = ref(false) const closing = ref(false) const alertEl = ref() - + let dismissTimer: undefined | number = undefined; const close = (event?: MouseEvent) => { - const dom = alertEl.value - dom.style.height = `${dom.offsetHeight}px` - // 重复一次后才能正确设置 height - dom.style.height = `${dom.offsetHeight}px` + dismissTimer && clearTimeout(dismissTimer) closing.value = true ctx.emit('close', event) } const afterLeave = () => { + dismissTimer = undefined; hide.value = true closing.value = false } onMounted(() => { if (props.dismissTime) { - setTimeout(() => { + dismissTimer = window.setTimeout(() => { close() }, props.dismissTime) } @@ -65,9 +63,8 @@ export default defineComponent({
    {props.closeable ? (
    diff --git a/packages/devui-vue/devui/auto-complete/__tests__/auto-complete.spec.ts b/packages/devui-vue/devui/auto-complete/__tests__/auto-complete.spec.ts new file mode 100644 index 0000000000..85df305ba4 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/__tests__/auto-complete.spec.ts @@ -0,0 +1,570 @@ +import { mount } from '@vue/test-utils'; +import { nextTick, ref } from 'vue'; +import DAutoComplete from '../src/auto-complete'; + +// delay api +const wait = (delay = 300) => + new Promise(resolve => setTimeout(() => resolve(true), delay)) +describe('auto-complete', () => { + it('init render & KeyboardEvent ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + return { + value, + source, + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(true) + expect(wrapper.find('.devui-dropdown-item').exists()).toBe(false) + expect(wrapper.find('.devui-auto-complete').attributes('style')).toContain( + 'width: 450px' + ) + await input.setValue('c') + await nextTick() + expect(wrapper.find('.devui-dropdown-menu').exists()).toBe(true) + await wait(300) + await nextTick() + expect(wrapper.find('.devui-list-unstyled').element.childElementCount).toBe(5) + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })) + await nextTick() + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await nextTick() + expect(wrapper.vm.value).toBe('C++') + input.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(false) + }) + it('disabled ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` +
    + + +
    + `, + setup() { + const value = ref('') + const isDisabled = ref(false) + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + function toggle(){ + isDisabled.value= !isDisabled.value + } + return { + value, + source, + isDisabled, + toggle + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + const button = wrapper.find('button') + expect(input.element.value).toBe('') + expect(button.element.innerHTML).toBe('Disable AutoComplete') + await input.trigger('click') + await nextTick() + await input.setValue('c') + await nextTick() + await wait(300) + await nextTick() + expect(wrapper.find('ul .selected').exists()).toBe(true) + const li = wrapper.find('ul .selected') + li.trigger('click') + await nextTick() + expect(wrapper.vm.value).toBe('C#') + expect(wrapper.find('.devui-select-open').exists()).toBe(false) + button.trigger('click') + await nextTick() + expect(button.element.innerHTML).toBe('Enable AutoComplete') + expect(input.element.disabled).toBe(true) + }) + it('Customized data matching method ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + + + `, + setup() { + const value = ref('') + const mySource = ref([ + { + label:'C#', + disabled:false + },{ + label:'C++', + disabled:false + },{ + label:'CPython', + disabled:false + },{ + label:'Java', + disabled:false + },{ + label:'JavaScript', + disabled:false + },{ + label:'Go', + disabled:false + },{ + label:'Ruby', + disabled:false + },{ + label:'F#', + disabled:false + },{ + label:'TypeScript', + disabled:false + },{ + label:'SQL', + disabled:true + },{ + label:'LiveScript', + disabled:false + },{ + label:'CoffeeScript', + disabled:false + } + ]) + const formatter = (item) =>{ + return item.label; + } + //trem:input输入内容 + const searchFn =async (trem)=>{ + const arr = [] + await new Promise((resolve)=>{ + setTimeout(() => { + resolve(1) + }, 500); + }) + mySource.value.forEach((item) => { + let cur = item.label + cur = cur.toLowerCase() + if (cur.startsWith(trem)) { + arr.push(item) + } + }) + return arr + } + return { + value, + searchFn, + formatter + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(true) + await input.setValue('c') + await nextTick() + await wait(300) + expect(wrapper.find('#devui-is-searching-template').exists()).toBe(true) + expect(wrapper.find('#devui-is-searching-template').element.innerHTML).toBe('c') + await wait(500) + await nextTick() + expect(wrapper.find('.devui-list-unstyled').element.childElementCount).toBe(4) + await input.setValue('s') + await nextTick() + await wait(300) + await nextTick() + await wait(500) + expect(wrapper.find('li.disabled').exists()).toBe(true) + expect(wrapper.find('li.disabled').element.innerHTML).toBe('SQL') + }) + + it('Customized template display', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + + + + `, + setup() { + const value = ref('') + const source = ref([ + 'C#', + 'C', + 'C++', + 'CPython', + 'Java', + 'JavaScript', + 'Go', + 'Python', + 'Ruby', + 'F#', + 'TypeScript', + 'SQL', + 'LiveScript', + 'CoffeeScript', + ]) + + return { + value, + source + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('.devui-select-open').exists()).toBe(true) + await input.setValue('c') + await nextTick() + await wait(300) + expect(wrapper.find('.devui-list-unstyled').exists()).toBe(true) + expect(wrapper.find('.devui-list-unstyled').element.childElementCount).toBe(5) + expect(wrapper.find('.selected div').element.innerHTML).toBe(' 第0项: C#') + await input.setValue('cc') + await nextTick() + await wait(300) + await nextTick() + expect(wrapper.find('#noResultItemTemplate').exists()).toBe(true) + expect(wrapper.find('#noResultItemTemplate').element.innerHTML).toBe('cc') + }) + + it('selectValue & transInputFocusEmit ', async () => { + const transInputFocusEmitCB = jest.fn() + const selectValueCB = jest.fn() + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + const selectValue = (e)=>{ + selectValueCB(e) + } + const transInputFocusEmit = (e)=>{ + transInputFocusEmitCB(e) + } + return { + value, + source, + selectValue, + transInputFocusEmit + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('focus') + await nextTick() + await input.setValue('c') + await nextTick() + await wait(300) + await nextTick() + expect(transInputFocusEmitCB).toHaveBeenCalledTimes(1) + const li = wrapper.find('ul .selected') + li.trigger('click') + await nextTick() + expect(selectValueCB).toHaveBeenCalledTimes(1) + }) + it('allowEmptyValueSearch ', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const allowEmptyValueSearch = ref(true) + const source = [ + 'C#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + + return { + value, + source, + allowEmptyValueSearch + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('focus') + await nextTick() + expect(wrapper.find('ul').element.childElementCount).toBe(5) + }) + + it('appendToBody & appendToBodyDirections', async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template: ` + + `, + setup() { + const value = ref('') + const allowEmptyValueSearch = ref(true) + const source = [ + 'CC#', + 'C', + 'C++', + 'CPython', + 'CoffeeScript', + ] + const appendToBodyDirections = ref({ + originX: 'left', + originY: 'bottom', + overlayX: 'left', + overlayY: 'top', + }) + return { + value, + source, + allowEmptyValueSearch, + appendToBodyDirections + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('focus') + await nextTick() + await input.setValue('c') + await nextTick() + await wait(300) + await nextTick() + expect(wrapper.find('ul').element.childElementCount).toBe(5) + expect(wrapper.find('.selected').element.innerHTML).toBe('CC#') + }) + + it('latestSource',async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template:` +
    + +
    + `, + setup(){ + const value = ref('') + const latestSource = ref(['JavaScript','TypeScript']) + const source = ref([ + 'C#', + 'C', + 'C++', + 'Java', + 'JavaScript' + ]) + + return { + value, + source, + latestSource + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.trigger('click') + await nextTick() + expect(wrapper.find('ul .devui-popup-tips').exists()).toBe(true) + await input.setValue('j') + await wait(300) + await nextTick() + const li = wrapper.find('ul .selected') + li.trigger('click') + await nextTick() + expect(wrapper.vm.value).toBe('Java') + }) + it('enableLazyLoad',async () => { + const wrapper = mount({ + components: {'d-auto-complete': DAutoComplete }, + template:` +
    + +
    + `, + setup(){ + const value = ref('') + const source = ref([ + 'C#', + 'C', + 'C++', + 'CPython', + 'Java', + 'JavaScript', + 'Go', + 'Python', + 'Ruby', + 'F#', + 'TypeScript', + 'SQL', + 'LiveScript', + 'CoffeeScript', + 'C1', + 'C2', + 'C3', + 'C4', + 'C5', + 'C6', + 'C7', + ]) + const autoCompleteRef =ref(null) + + const loadMore = () => { + setTimeout(() => { + source.value.push('lazyData'+source.value.length) + autoCompleteRef.value?.loadFinish() + },3000) + } + return { + value, + source, + loadMore, + autoCompleteRef + } + } + }) + expect(wrapper.find('.devui-auto-complete').exists()).toBe(true) + const input = wrapper.find('input') + expect(input.element.value).toBe('') + await input.setValue('c') + await nextTick() + expect(wrapper.find('.devui-dropdown-menu').exists()).toBe(true) + await wait(300) + await nextTick() + expect(wrapper.find('.devui-dropdown-item').exists()).toBe(true) + const ul = wrapper.find('.devui-list-unstyled') + const makeScroll = async ( + dom: Element, + name: 'scrollTop', + offset: number + ) => { + const eventTarget = dom === document.documentElement ? window : dom + dom[name] = offset + const evt = new CustomEvent('scroll', { + detail: { + target: { + [name]: offset, + }, + }, + }) + eventTarget.dispatchEvent(evt) + return await wait(3000) + } + await makeScroll(ul.element, 'scrollTop', 500) + await nextTick() + expect(wrapper.vm.value).toBe('c') + await nextTick() + await input.setValue('') + const length = wrapper.vm.source.length + expect(wrapper.vm.source[length - 1]).toBe('lazyData21') + await input.setValue('la') + await wait(300) + await nextTick() + expect(wrapper.find('.devui-dropdown-item').element.innerHTML).toBe('lazyData21') + }) +}) + diff --git a/packages/devui-vue/devui/auto-complete/index.ts b/packages/devui-vue/devui/auto-complete/index.ts new file mode 100644 index 0000000000..ecfbe3545b --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import AutoComplete from './src/auto-complete' + +AutoComplete.install = function(app: App): void { + app.component(AutoComplete.name, AutoComplete) +} + +export { AutoComplete } + +export default { + title: 'AutoComplete 自动补全', + category: '数据录入', + status: '100%', + install(app: App): void { + app.use(AutoComplete as any) + } +} diff --git a/packages/devui-vue/devui/auto-complete/src/auto-complete-types.ts b/packages/devui-vue/devui/auto-complete/src/auto-complete-types.ts new file mode 100644 index 0000000000..b3618cd2f2 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/auto-complete-types.ts @@ -0,0 +1,140 @@ +import type { PropType, ExtractPropTypes, InjectionKey, SetupContext, Ref } from 'vue' +const defaultFormatter = (item) => (item ? item.label || item.toString() : ''); +const defaultValueParse = (item) => item; +// appendToBody使用 +export type HorizontalConnectionPos = 'left' | 'center' | 'right'; +export type VerticalConnectionPos = 'top' | 'center' | 'bottom'; +export interface ConnectionPosition { + originX: HorizontalConnectionPos + originY: VerticalConnectionPos + overlayX: HorizontalConnectionPos + overlayY: VerticalConnectionPos +} +export const autoCompleteProps = { + modelValue: { + type: String, + default:'' + }, + source:{ + type :Array, + default:null + }, + allowEmptyValueSearch:{ + type:Boolean, + default:false + }, + appendToBody :{ + type:Boolean, + default:false + }, + appendToBodyDirections :{ + type: Object as PropType, + default: (): ConnectionPosition => ({ + originX: 'left', + originY: 'bottom', + overlayX: 'left', + overlayY: 'top', + }), + }, + disabled:{ + type:Boolean, + default:false + }, + delay:{ + type:Number, + default:300 + }, + disabledKey:{ + type:String, + default:null + }, + formatter: { + type:Function as PropType<(item: any) => string>, + default:defaultFormatter + }, + isSearching: { + type:Boolean, + default:false + }, + sceneType:{ + type:String, + default:null + }, + searchFn:{ + type:Function as PropType<(term: string) => Array>, + default:null + }, + tipsText:{ + type:String, + default:'最近输入' + }, + latestSource:{ + type:Array, + default:null + }, + valueParser:{ + type:Function as PropType<(item: any) => any>, + default:defaultValueParse + }, + enableLazyLoad: { + type:Boolean, + default:false + }, + dAutoCompleteWidth:{ + type: Number, + default:null + }, + showAnimation:{ + type:Boolean, + default:true + }, + maxHeight:{ + type:Number, + default:300 + }, + transInputFocusEmit:{ + type:Function as PropType<(any) => void>, + default:null + }, + selectValue:{ + type:Function as PropType<(any) => void>, + default:null + }, + loadMore:{ + type:Function as PropType<() => void>, + default:null + } +} as const + +export type AutoCompleteProps = ExtractPropTypes + +export interface AutoCompleteRootType { + ctx:SetupContext + props:AutoCompleteProps +} +export type SearchFnType = (term: string) => Array +export type FormatterType = (item: any) => string +export type DefaultFuncType = (any?) => any +export type HandleSearch = (term?:string | string,enableLazyLoad?:boolean) => void +export type RecentlyFocus = (latestSource:Array) => void +export type InputDebounceCb = (...rest:any) => Promise +export type TransInputFocusEmit = (any?: any) => void +export type SelectOptionClick = (any?: any) => void +//弹出选择框参数 +export type DropdownProps = { + props:AutoCompleteProps + searchList:Ref + searchStatus?:Ref + showNoResultItemTemplate:Ref + term?: string + visible: Ref + selectedIndex:Ref + selectOptionClick:HandleSearch + dropDownRef + showLoading:Ref + loadMore + latestSource + modelValue:Ref + hoverIndex:Ref +} +export const DropdownPropsKey:InjectionKey=Symbol('DropdownPropsKey') diff --git a/packages/devui-vue/devui/auto-complete/src/auto-complete.scss b/packages/devui-vue/devui/auto-complete/src/auto-complete.scss new file mode 100644 index 0000000000..4d80b0cd39 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/auto-complete.scss @@ -0,0 +1,95 @@ +@import '../../style/mixins/index'; +@import '../../style/theme/color'; + +.devui-auto-complete, +.devui-auto-complete-menu { + .devui-dropdown-menu { + left: 0 !important; + top: 0 !important; + } +} + +.devui-auto-complete { + .active { + background: $devui-list-item-hover-bg; + } + + .devui-dropdown-menu { + width: 100%; + display: block; + } + + .devui-dropdown-menu-cdk { + position: static; + } + + .devui-dropdown-item { + cursor: pointer; + display: block; + width: 100%; + padding: 8px 12px; + clear: both; + border: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 14px; + } + + .devui-dropdown-menu { + .devui-dropdown-item:not(.disabled) { + &.selected { + color: $devui-list-item-active-text; + background-color: $devui-list-item-active-bg; + } + } + } + + .devui-no-result-template, + .devui-is-searching-template { + display: block; + width: 100%; + padding: 8px 12px; + clear: both; + border: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: not-allowed; + background-color: $devui-disabled-bg; + color: $devui-disabled-text; + line-height: 14px; + + &:hover, + &:active, + &:hover:active { + background-color: $devui-unavailable; + } + } + + /* 选项disabled */ + .devui-dropdown-item.disabled, + .devui-dropdown-item.disabled:hover { + cursor: not-allowed; + color: $devui-disabled-text; + } + + ul.devui-list-unstyled { + margin: 0; + overflow-y: auto; + } + + .devui-dropdown-bg { + background: $devui-list-item-hover-bg; + color: $devui-list-item-hover-text; + } + + .devui-popup-tips { + color: $devui-text-weak; // TODO: Color-Question + padding: 4px 12px; + } + + .devui-dropdown-latestSource ul { + line-height: initial !important; + } +} diff --git a/packages/devui-vue/devui/auto-complete/src/auto-complete.tsx b/packages/devui-vue/devui/auto-complete/src/auto-complete.tsx new file mode 100644 index 0000000000..0f70a1e350 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/auto-complete.tsx @@ -0,0 +1,127 @@ +import { defineComponent, provide, reactive, Transition,toRefs, ref, SetupContext } from 'vue' +import { autoCompleteProps, AutoCompleteProps, DropdownPropsKey } from './auto-complete-types' +import useCustomTemplate from './composables/use-custom-template' +import useSearchFn from './composables/use-searchfn' +import useInputHandle from './composables/use-input-handle' +import useSelectHandle from './composables/use-select-handle' +import useLazyHandle from './composables/use-lazy-handle' +import useKeyBoardHandle from './composables/use-keyboard-select' +import './auto-complete.scss' +import DAutoCompleteDropdown from './components/dropdown' +import ClickOutside from '../../shared/devui-directive/clickoutside' +import {FlexibleOverlay} from '../../overlay/src/flexible-overlay' +export default defineComponent({ + name: 'DAutoComplete', + directives: { ClickOutside }, + props: autoCompleteProps, + emits: ['update:modelValue'], + setup(props: AutoCompleteProps, ctx:SetupContext) { + const { + disabled, + modelValue, + appendToBody, + dAutoCompleteWidth, + delay, + allowEmptyValueSearch, + formatter, + transInputFocusEmit, + selectValue, + source, + searchFn, + appendToBodyDirections, + latestSource, + showAnimation + } = toRefs(props) + + const {handleSearch,searchList,showNoResultItemTemplate,recentlyFocus} = useSearchFn(ctx,allowEmptyValueSearch,source,searchFn,formatter) + const {onInput,onFocus,inputRef,visible,searchStatus,handleClose,toggleMenu} = useInputHandle(ctx,searchList,showNoResultItemTemplate,modelValue,disabled,delay,handleSearch,transInputFocusEmit,recentlyFocus,latestSource) + const {selectedIndex,selectOptionClick} = useSelectHandle(ctx,searchList,selectValue,handleSearch,formatter,handleClose) + const {showLoading,dropDownRef,loadMore} = useLazyHandle(props,ctx,handleSearch) + const {customRenderSolts} = useCustomTemplate(ctx,modelValue) + const {hoverIndex,handlekeyDown} = useKeyBoardHandle(dropDownRef,visible,searchList,selectedIndex,searchStatus,showNoResultItemTemplate,selectOptionClick,handleClose) + provide(DropdownPropsKey, { + props, + visible, + term: '', + searchList:searchList, + selectedIndex, + searchStatus, + selectOptionClick, + dropDownRef, + showLoading, + loadMore, + latestSource, + modelValue, + showNoResultItemTemplate:showNoResultItemTemplate, + hoverIndex:hoverIndex + }) + const origin = ref() + const position = reactive({appendToBodyDirections:{}}) + position.appendToBodyDirections=appendToBodyDirections + const renderDropdown = () => { + if(appendToBody.value){ + return ( + +
    0&&dAutoCompleteWidth.value+'px' + }} + > + + {customRenderSolts()} + +
    +
    + ) + }else{ + return ( +
    0&&dAutoCompleteWidth.value+'px' + }} + > + + + {customRenderSolts()} + + +
    + ) + } + + } + return () => { + return ( +
    0&&dAutoCompleteWidth.value+'px' + }} + > + + {renderDropdown()} +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/auto-complete/src/components/dropdown.tsx b/packages/devui-vue/devui/auto-complete/src/components/dropdown.tsx new file mode 100644 index 0000000000..38dd7ed3a3 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/components/dropdown.tsx @@ -0,0 +1,103 @@ +import { defineComponent, inject } from 'vue' +import { DropdownPropsKey } from '../auto-complete-types' +import dLoading from '../../../loading/src/directive' +// 后续会对接自带下拉组件,相关功能将全部抽离 +export default defineComponent({ + name: 'DAutoCompleteDropdown', + directives: {dLoading}, + setup(props,ctx) { + const propsData = inject(DropdownPropsKey) + const { + visible, + selectedIndex, + selectOptionClick, + searchList, + searchStatus, + dropDownRef, + loadMore, + showLoading, + showNoResultItemTemplate, + latestSource, + modelValue, + hoverIndex + } = propsData + const { + disabled, + maxHeight, + appendToBody, + formatter, + disabledKey, + isSearching, + } = propsData.props + + const onSelect =(item:any)=>{ + if(item[disabledKey]){return} + selectOptionClick(item) + } + return () => { + return ( +
    0)||(ctx.slots.noResultItemTemplate&&showNoResultItemTemplate.value)||(isSearching&&ctx.slots.searchingTemplate&&searchStatus.value)} + > +
      + {/* 搜索中展示 */} + { + isSearching&&ctx.slots.searchingTemplate&&searchStatus.value + &&
    • +
      + { + ctx.slots.searchingTemplate() + } +
      + +
    • + } + { + latestSource.value&&!modelValue.value&&
    • 最近输入
    • + } + {/* 展示 */} + { + !showNoResultItemTemplate.value&&!searchStatus.value&&searchList!=null&&searchList.value.length>0&&searchList.value.map((item,index)=>{ + return ( +
    • onSelect(item)} + class={[ + 'devui-dropdown-item',selectedIndex.value==index&&'selected', + {'disabled': disabledKey && item[disabledKey]}, + {'devui-dropdown-bg': hoverIndex.value== index}, + + ]} + title={formatter(item)} + key={formatter(item)} + > + { + ctx.slots.itemTemplate?ctx.slots.itemTemplate(item,index):formatter(item) + } +
    • + ) + }) + } + + {/* 没有匹配结果传入了noResultItemTemplate*/} + { + !searchStatus.value&&searchList.value.length==0&&ctx.slots.noResultItemTemplate&&showNoResultItemTemplate.value&& +
    • +
      + {ctx.slots.noResultItemTemplate()} +
      +
    • + } +
    +
    + ) + } + + } +}) \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-custom-template.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-custom-template.ts new file mode 100644 index 0000000000..5ddd8a3ec9 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-custom-template.ts @@ -0,0 +1,37 @@ +import { Ref, SetupContext } from 'vue'; + +export default function useCustomTemplate(ctx:SetupContext,modelValue:Ref): any { + const itemTemplate = (item, index) => { + const arr = { item, index } + if (ctx.slots.itemTemplate) { + return ctx.slots.itemTemplate(arr) + } + return null + } + const noResultItemTemplate = () => { + if (ctx.slots.noResultItemTemplate) { + return ctx.slots.noResultItemTemplate(modelValue.value) + } + return null + } + const searchingTemplate = () => { + if (ctx.slots.searchingTemplate) { + return ctx.slots.searchingTemplate(modelValue.value) + } + return null + } + const customRenderSolts = () => { + const slots = {} + if (ctx.slots.itemTemplate) { + slots['itemTemplate'] = itemTemplate + } + if (ctx.slots.noResultItemTemplate) { + slots['noResultItemTemplate'] = noResultItemTemplate + } + if (ctx.slots.searchingTemplate) { + slots['searchingTemplate'] = searchingTemplate + } + return slots + } + return {customRenderSolts} +} \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-input-handle.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-input-handle.ts new file mode 100644 index 0000000000..090a867276 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-input-handle.ts @@ -0,0 +1,63 @@ +import { ref, Ref, SetupContext } from 'vue'; +import {HandleSearch,RecentlyFocus,InputDebounceCb,TransInputFocusEmit} from '../auto-complete-types' +export default function useInputHandle(ctx: SetupContext,searchList:Ref,showNoResultItemTemplate:Ref, modelValue:Ref,disabled:Ref,delay:Ref,handleSearch: HandleSearch, transInputFocusEmit:Ref,recentlyFocus:RecentlyFocus,latestSource:Ref>): any { + const visible = ref(false) + const inputRef = ref() + const searchStatus = ref(false) + const debounce =(cb:InputDebounceCb,time:number) =>{ + let timer + return (...args)=>{ + if(timer){ + clearTimeout(timer) + } + timer = setTimeout(async ()=>{ + searchStatus.value=true + await cb(...args) + searchStatus.value=false + },time) + } + } + const onInputCb = async(value:string)=>{ + await handleSearch(value) + visible.value = true + } + const onInputDebounce = debounce(onInputCb,delay.value) + const onInput =(e: Event) => { + const inp = e.target as HTMLInputElement + searchStatus.value=false + showNoResultItemTemplate.value=false + ctx.emit('update:modelValue', inp.value) + onInputDebounce(inp.value) + } + const onFocus =() => { + handleSearch(modelValue.value) + recentlyFocus(latestSource.value) + transInputFocusEmit.value && transInputFocusEmit.value() + } + const handleClose = ()=>{ + visible.value=false + searchStatus.value=false + showNoResultItemTemplate.value=false + } + const toggleMenu =()=>{ + if(!disabled.value){ + if(visible.value){ + handleClose() + }else{ + visible.value=true + if (ctx.slots.noResultItemTemplate&&searchList.value.length==0&&modelValue.value.trim()!='') { + showNoResultItemTemplate.value=true + } + } + } + } + return { + handleClose, + toggleMenu, + onInput, + onFocus, + inputRef, + visible, + searchStatus + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-keyboard-select.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-keyboard-select.ts new file mode 100644 index 0000000000..ad04b34793 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-keyboard-select.ts @@ -0,0 +1,55 @@ +import { nextTick, ref, Ref } from 'vue'; +import { DefaultFuncType, SelectOptionClick } from '../auto-complete-types'; + +export default function useKeyBoardHandle(dropDownRef: Ref, visible: Ref, searchList: Ref>, selectedIndex: Ref, searchStatus: Ref, showNoResultItemTemplate: Ref, selectOptionClick: SelectOptionClick, handleClose: DefaultFuncType): any { + const hoverIndex = ref(selectedIndex.value??0) + const scrollToActive = (index: number) => { + const ul = dropDownRef.value + const li = ul.children[index] + nextTick(() => { + if (li.scrollIntoViewIfNeeded) { + li.scrollIntoViewIfNeeded(false) + } else { + const containerInfo = ul.getBoundingClientRect() + const elementInfo = li.getBoundingClientRect() + if (elementInfo.bottom > containerInfo.bottom || elementInfo.top < containerInfo.top) { + li.scrollIntoView(false) + } + } + }) + } + const handlekeyDown = (e: KeyboardEvent) => { + const keyCode = e.key || e.code + if (keyCode === 'Escape' && ( (visible.value && searchList.value.length) || searchStatus.value||showNoResultItemTemplate.value)) { + handleClose() + return + } + const status = visible.value && searchList.value.length && !searchStatus.value && !showNoResultItemTemplate.value + if (keyCode === 'ArrowDown' && status) { + if (hoverIndex.value === searchList.value.length - 1) { + hoverIndex.value = 0; + scrollToActive(hoverIndex.value); + return; + } + hoverIndex.value = hoverIndex.value + 1; + scrollToActive(hoverIndex.value); + } else if (keyCode === 'ArrowUp' && status) { + if (hoverIndex.value === 0) { + hoverIndex.value = searchList.value.length - 1; + scrollToActive(hoverIndex.value); + return; + } + hoverIndex.value = hoverIndex.value - 1; + scrollToActive(hoverIndex.value); + } + if (keyCode === 'Enter' && status) { + selectOptionClick(searchList.value[hoverIndex.value]) + hoverIndex.value=selectedIndex.value??0 + return + } + } + return { + hoverIndex, + handlekeyDown + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-lazy-handle.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-lazy-handle.ts new file mode 100644 index 0000000000..96add63862 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-lazy-handle.ts @@ -0,0 +1,30 @@ +import { ref,SetupContext } from 'vue' +import { AutoCompleteProps,HandleSearch } from '../auto-complete-types' +export default function useLazyHandle(props: AutoCompleteProps,ctx:SetupContext,handleSearch:HandleSearch):any { + const showLoading = ref(false) + const dropDownRef = ref() + const loadMore = () => { + if(!props.enableLazyLoad && showLoading) return + const dropDownValue = dropDownRef.value + const height = dropDownValue.scrollHeight + const scrollTop = dropDownValue.clientHeight + dropDownValue.scrollTop + + if(scrollTop >= height && scrollTop >= props.maxHeight) { + props.loadMore() + showLoading.value = true + } + } + ctx.expose({loadFinish}) + + async function loadFinish (){ + await handleSearch(props.modelValue,props.enableLazyLoad) + showLoading.value = false + } + return { + showLoading, + dropDownRef, + loadMore, + } +} + + diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-searchfn.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-searchfn.ts new file mode 100644 index 0000000000..8d90a98811 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-searchfn.ts @@ -0,0 +1,45 @@ +import { ref, Ref, SetupContext } from 'vue'; +import { FormatterType, SearchFnType } from '../auto-complete-types'; +export default function useSearchFn(ctx: SetupContext,allowEmptyValueSearch:Ref,source:Ref>,searchFn:Ref,formatter:Ref): any { + const searchList = ref([]) + const showNoResultItemTemplate = ref(false) + const handleSearch = async (term: string,enableLazyLoad:boolean) => { + if (term == ''&&!allowEmptyValueSearch.value) { + searchList.value = [] + showNoResultItemTemplate.value=false + return + } + let arr = [] + term = term.toLowerCase() + if(enableLazyLoad) { + arr = source.value + }else if (!searchFn.value) { + source.value.forEach((item) => { + let cur = formatter.value(item) + cur = cur.toLowerCase() + if (cur.startsWith(term)) { + arr.push(item) + } + }) + } else { + arr = await searchFn.value(term) + } + searchList.value = arr + if(searchList.value.length==0){ + showNoResultItemTemplate.value=true + }else{ + showNoResultItemTemplate.value=false + } + } + const recentlyFocus = (latestSource:Array) => { + if(latestSource) { + searchList.value = latestSource + } + } + return { + handleSearch, + recentlyFocus, + searchList, + showNoResultItemTemplate + } +} diff --git a/packages/devui-vue/devui/auto-complete/src/composables/use-select-handle.ts b/packages/devui-vue/devui/auto-complete/src/composables/use-select-handle.ts new file mode 100644 index 0000000000..8261410c70 --- /dev/null +++ b/packages/devui-vue/devui/auto-complete/src/composables/use-select-handle.ts @@ -0,0 +1,25 @@ +import { ref, Ref, SetupContext } from 'vue'; +import { DefaultFuncType,FormatterType,HandleSearch } from '../auto-complete-types'; + +export default function useSelectHandle(ctx: SetupContext,searchList: Ref>, selectValue: Ref, handleSearch: HandleSearch,formatter: Ref,handleClose:DefaultFuncType): any { + const selectedIndex = ref(0) + const getListIndex = (item: string) => { + if (searchList.value.length == 0) { + return 0 + } + const ind = searchList.value.indexOf(item) + return ind == -1 ? 0 : ind + } + const selectOptionClick = async(item: any) => { + const cur = formatter.value(item) + ctx.emit('update:modelValue', cur) + handleClose() + await handleSearch(cur) + selectedIndex.value = getListIndex(cur) + selectValue.value && selectValue.value() + } + return { + selectedIndex, + selectOptionClick + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/back-top/src/back-top.scss b/packages/devui-vue/devui/back-top/src/back-top.scss index e0d28a369d..20d44bf48c 100644 --- a/packages/devui-vue/devui/back-top/src/back-top.scss +++ b/packages/devui-vue/devui/back-top/src/back-top.scss @@ -6,6 +6,7 @@ height: 40px; cursor: pointer; z-index: 9; + .devui-back-top-base { width: 40px; height: 40px; @@ -13,16 +14,19 @@ display: flex; align-items: center; justify-content: center; + &:hover { opacity: 1; } } + .devui-back-top-content { opacity: 0.4; background-color: $devui-text-weak; box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); } + .devui-backtop-custom { - background-color: #fff; + background-color: #ffffff; } } diff --git a/packages/devui-vue/devui/back-top/src/back-top.tsx b/packages/devui-vue/devui/back-top/src/back-top.tsx index 1939a4e015..fa08d4857c 100644 --- a/packages/devui-vue/devui/back-top/src/back-top.tsx +++ b/packages/devui-vue/devui/back-top/src/back-top.tsx @@ -13,7 +13,7 @@ export default defineComponent({ const backTopRef = ref(null) const position = usePosition(props) - let isVisible = useVisibility(props, backTopRef) + const isVisible = useVisibility(props, backTopRef) const scrollToTop = () => { // toTop方法暂定 diff --git a/packages/devui-vue/devui/badge/index.ts b/packages/devui-vue/devui/badge/index.ts index 8657a733b7..d6a4e971d7 100644 --- a/packages/devui-vue/devui/badge/index.ts +++ b/packages/devui-vue/devui/badge/index.ts @@ -1,17 +1,14 @@ -import type { App } from 'vue' -import Badge from './src/badge' +import type { App } from 'vue'; +import Badge from './src/badge'; +export * from './src/badge-types'; -Badge.install = function (app: App) { - app.component(Badge.name, Badge) -} - -export { Badge } +export { Badge }; export default { title: 'Badge 徽标', category: '数据展示', status: '100%', install(app: App): void { - app.use(Badge as any) - } -} + app.component(Badge.name, Badge); + }, +}; diff --git a/packages/devui-vue/devui/badge/src/badge-types.ts b/packages/devui-vue/devui/badge/src/badge-types.ts index b0a5ba2fdf..ee212e5311 100644 --- a/packages/devui-vue/devui/badge/src/badge-types.ts +++ b/packages/devui-vue/devui/badge/src/badge-types.ts @@ -1,41 +1,41 @@ -import type { PropType, ExtractPropTypes } from 'vue' +import type { PropType, ExtractPropTypes } from 'vue'; -type BadgeStatusType = PropType<'danger' | 'warning' | 'waiting' | 'success' | 'info'> -type BadgePositionType = PropType<'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'> +export type BadgeStatusType = 'danger' | 'warning' | 'waiting' | 'success' | 'info'; +export type BadgePositionType = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; -const badgeStatusType = ['danger', 'warning', 'waiting', 'success', 'info'] -const badgePositionType = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] +const badgeStatusType = ['danger', 'warning', 'waiting', 'success', 'info']; +const badgePositionType = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; export const badgeProps = { count: { - type: [Number, String] + type: [Number, String], }, maxCount: { type: Number, - default: 99 + default: 99, }, showDot: { type: Boolean, - default: false + default: false, }, status: { - type: String as BadgeStatusType, - validator: (val: string) => badgeStatusType.includes(val) + type: String as PropType, + validator: (val: string): boolean => badgeStatusType.includes(val), }, - badgePos: { - type: String as BadgePositionType, + position: { + type: String as PropType, default: 'top-right', - validator: (val: string) => badgePositionType.includes(val) + validator: (val: string): boolean => badgePositionType.includes(val), }, - offsetXY: { - type: Array + offset: { + type: Array as PropType>, }, bgColor: { - type: String + type: String, }, textColor: { - type: String - } -} + type: String, + }, +}; -export type BadgeProps = ExtractPropTypes +export type BadgeProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/badge/src/badge.tsx b/packages/devui-vue/devui/badge/src/badge.tsx index ad6024bd19..a4b428eca6 100644 --- a/packages/devui-vue/devui/badge/src/badge.tsx +++ b/packages/devui-vue/devui/badge/src/badge.tsx @@ -1,65 +1,60 @@ -import './badge.scss' - -import { defineComponent, computed } from 'vue' -import { badgeProps, BadgeProps } from './badge-types' +import { defineComponent, computed } from 'vue'; +import { badgeProps, BadgeProps } from './badge-types'; +import './badge.scss'; export default defineComponent({ name: 'DBadge', props: badgeProps, - emits: [], setup(props: BadgeProps, ctx) { const className = computed(() => { - const base = 'devui-badge-content' + const base = 'devui-badge-content'; return [ base, props.showDot ? `${base}-dot` : `${base}-count`, props.status && `${base}-${props.status}`, - ctx.slots.default && props.badgePos && `${base}-${props.badgePos}`, - ctx.slots.default && `${base}-fixed` - ].join(' ') - }) + ctx.slots.default && props.position && `${base}-${props.position}`, + ctx.slots.default && `${base}-fixed`, + ].join(' '); + }); const style = computed(() => { const styleMap = { bgColor: 'background', - textColor: 'color' - } - const ret = Object.keys(styleMap).reduce((ret, key) => { - if (props[key]) { - ret[styleMap[key]] = props[key] - } - return ret - }, {}) - // 偏移量 - if (ctx.slots.default && props.offsetXY) { - const [x, y]: Array = props.offsetXY as Array - const [yName, xName] = (props.badgePos as string).split('-') - ret[yName] = y + 'px' - ret[xName] = x + 'px' + textColor: 'color', + }; + const ret = Object.keys(styleMap).reduce((result, key) => { + props[key] && (result[styleMap[key]] = props[key]); + return result; + }, {}); + if (ctx.slots.default && props.offset) { + const [x, y]: Array = props.offset; + const [yName, xName] = props.position.split('-'); + ret[yName] = y + 'px'; + ret[xName] = x + 'px'; } - return ret - }) + return ret; + }); const text = computed(() => { if (props.showDot) { - return + return; } if (typeof props.count === 'number' && typeof props.maxCount === 'number') { - return props.count > props.maxCount ? `${props.maxCount}+` : props.count + return props.count > props.maxCount ? `${props.maxCount}+` : props.count; } - return props.count - }) + return props.count; + }); return () => { return ( -
    +
    {ctx.slots.default?.()}
    {text.value}
    - ) - } - } -}) + ); + }; + }, +}); diff --git a/packages/devui-vue/devui/button/__tests__/button.spec.ts b/packages/devui-vue/devui/button/__tests__/button.spec.ts index a03789608d..3c60844552 100644 --- a/packages/devui-vue/devui/button/__tests__/button.spec.ts +++ b/packages/devui-vue/devui/button/__tests__/button.spec.ts @@ -2,9 +2,9 @@ import { mount } from '@vue/test-utils'; import Button from '../src/button'; describe('d-button', () => { - it('btnStyle', () => { + it('variant', () => { const wrapper = mount(Button, { - props: { btnStyle: 'danger' }, + props: { variant: 'danger' }, }); expect(wrapper.find('.devui-btn').classes()).toContain('devui-btn-danger'); }); @@ -36,17 +36,17 @@ describe('d-button', () => { }); // 目前还不支持 loading - // it('loading', async () => { - // const handleClick = jest.fn(); - // const wrapper = mount(Button, { - // props: { - // showLoading: true, - // btnClick: handleClick - // }, - // }); - // await wrapper.trigger('click'); - // expect(handleClick).not.toBeCalled(); - // }); + it('loading', async () => { + const handleClick = jest.fn(); + const wrapper = mount(Button, { + props: { + showLoading: true, + btnClick: handleClick + }, + }); + await wrapper.trigger('click'); + expect(handleClick).not.toBeCalled(); + }); it('disabled', async () => { const handleClick = jest.fn(); @@ -64,7 +64,7 @@ describe('d-button', () => { it('slot', () => { const btnText = 'vue3 devui'; const wrapper = mount(Button, { - slots: { + slots: { default: btnText } }); diff --git a/packages/devui-vue/devui/button/index.ts b/packages/devui-vue/devui/button/index.ts index 91a9eb8d81..d2d44df9a1 100644 --- a/packages/devui-vue/devui/button/index.ts +++ b/packages/devui-vue/devui/button/index.ts @@ -1,21 +1,15 @@ -import type { App } from 'vue' -import Button from './src/button' -import { Loading } from '../loading/index' +import type { App } from 'vue'; +import Button from './src/button'; -Button.install = function (app: App) { - app.directive('dLoading', Loading) - app.component(Button.name, Button) -} +export * from './src/button-types'; -export * from './src/button-types' - -export { Button } +export { Button }; export default { title: 'Button 按钮', category: '通用', status: '100%', install(app: App): void { - app.use(Button as any) - } -} + app.component(Button.name, Button); + }, +}; diff --git a/packages/devui-vue/devui/button/src/button-types.ts b/packages/devui-vue/devui/button/src/button-types.ts index 2c09d2a67a..f489e4beb8 100644 --- a/packages/devui-vue/devui/button/src/button-types.ts +++ b/packages/devui-vue/devui/button/src/button-types.ts @@ -1,54 +1,40 @@ -import { ExtractPropTypes, PropType } from 'vue'; +import type { ComputedRef, ExtractPropTypes, PropType } from 'vue'; -export type IButtonType = 'button' | 'submit' | 'reset'; -export type IButtonStyle = 'common' | 'primary' | 'text' | 'text-dark' | 'danger'; -export type IButtonPosition = 'left' | 'right' | 'default'; +export type IButtonVariant = 'solid' | 'outline' | 'text'; +export type IButtonColor = 'secondary' | 'primary' | 'danger'; export type IButtonSize = 'lg' | 'md' | 'sm' | 'xs'; export const buttonProps = { - type: { - type: String as PropType, - default: 'button' - }, - btnStyle: { - type: String as PropType, - default: 'primary' + variant: { + type: String as PropType, + default: 'outline', }, size: { type: String as PropType, - default: 'md' - }, - position: { - type: String as PropType, - default: 'default' + default: 'md', }, - bordered: { - type: Boolean, - default: false + color: { + type: String as PropType, }, icon: { type: String, - default: '' + default: '', }, - showLoading: { + loading: { type: Boolean, - default: false - }, - width: { - type: String, + default: false, }, disabled: { type: Boolean, - default: false - }, - autofocus: { - type: Boolean, - default: false + default: false, }, - onClick: { - type: Function as PropType<(event: MouseEvent) => void> - } } as const; +export type ButtonProps = ExtractPropTypes; -export type ButtonProps = ExtractPropTypes; \ No newline at end of file +export interface UseButtonReturnType { + classes: ComputedRef<{ + [key: string]: string | boolean; + }>; + iconClass: ComputedRef; +} diff --git a/packages/devui-vue/devui/button/src/button.scss b/packages/devui-vue/devui/button/src/button.scss index a29c626694..9287577fa1 100644 --- a/packages/devui-vue/devui/button/src/button.scss +++ b/packages/devui-vue/devui/button/src/button.scss @@ -4,281 +4,236 @@ @import '../../style/theme/font'; @import '../../style/theme/corner'; -$devui-btn-loading-color: $devui-text; -$devui-btn-xs-padding: 1px 5px; -$devui-btn-xs-height: 24px; -$devui-btn-icon-xs-min-width: 24px; -$devui-btn-xs-min-width: 48px; -$devui-btn-sm-padding: 1px 15px; -$devui-btn-sm-min-width: 56px; -$devui-btn-icon-sm-min-width: 24px; -$devui-btn-sm-height: 24px; -$devui-btn-min-width: 64px; -$devui-btn-height: 28px; -$devui-btn-padding: 3px 20px; -$devui-btn-lg-padding: 5px 23px; -$devui-btn-lg-min-width: 72px; -$devui-btn-icon-lg-min-width: 32px; -$devui-btn-lg-height: 32px; -$devui-btn-xs-font-size: $devui-font-size-sm; -$devui-btn-sm-font-size: $devui-font-size-sm; -$devui-btn-font-size: $devui-font-size-md; -$devui-btn-lg-font-size: $devui-font-size-lg; -$devui-btn-line-height: $devui-line-height-base; - -$devui-btn-normal-config: ( - text: ( - color: $devui-brand-active, - padding: 0 - ), - text-dark: ( - color: $devui-brand-active, - padding: 0 - ), - common: ( - color: $devui-text, - min-width: $devui-btn-min-width, - background-color: $devui-block, - border-color: $devui-line - ), - stress: ( - color: $devui-light-text, - min-width: $devui-btn-min-width, - background-color: $devui-primary - ), - primary: ( - color: $devui-light-text, - min-width: $devui-btn-min-width, - background-color: $devui-primary - ), - danger: ( - color: $devui-light-text, - min-width: $devui-btn-min-width, - background-color: $devui-contrast - ), - success: ( - color: $devui-light-text, - min-width: $devui-btn-min-width, - background-color: $devui-success - ), - warning: ( - color: $devui-light-text, - min-width: $devui-btn-min-width, - background-color: $devui-warning - ), - xs: ( - padding: $devui-btn-xs-padding, - height: $devui-btn-xs-height, - font-size: $devui-btn-xs-font-size, - min-width: $devui-btn-xs-min-width - ), - sm: ( - padding: $devui-btn-sm-padding, - height: $devui-btn-sm-height, - font-size: $devui-btn-sm-font-size, - min-width: $devui-btn-sm-min-width, - ), - lg: ( - padding: $devui-btn-lg-padding, - height: $devui-btn-lg-height, - font-size: $devui-btn-lg-font-size, - min-width: $devui-btn-lg-min-width, - ), - left: ( - border-radius: $devui-border-radius 0 0 $devui-border-radius, - ), - right: ( - border-radius: 0 $devui-border-radius $devui-border-radius 0, - ), - default: ( - border-radius: $devui-border-radius, - ) -); - -$devui-btn-pseudo-config: ( - text: ( - hover: ( - background-color: transparent, - color: $devui-brand-active-focus - ), - focus: ( - background-color: transparent, - color: $devui-brand-active-focus - ), - active: ( - background-color: transparent, - color: $devui-brand-active-focus - ) - ), - text-dark: ( - hover: ( - background-color: transparent, - color: $devui-brand-active-focus - ), - focus: ( - background-color: transparent, - color: $devui-brand-active-focus - ), - active: ( - background-color: transparent, - color: $devui-brand-active-focus - ) - ), - common: ( - hover: ( - border-color: $devui-form-control-line-active, - color: $devui-brand-active - ), - focus: ( - border-color: $devui-form-control-line-active, - color: $devui-brand-active - ), - active: ( - border-color: $devui-form-control-line-active, - color: $devui-brand-active - ) - ), - stress: ( - hover: ( - background-color: $devui-primary-hover - ), - focus: ( - background-color: $devui-primary-hover - ), - active: ( - background-color: $devui-primary-active - ) - ), - primary: ( - hover: ( - background-color: $devui-primary-hover - ), - focus: ( - background-color: $devui-primary-hover - ), - active: ( - background-color: $devui-primary-active - ) - ), - danger: ( - hover: ( - background-color: $devui-contrast-hover - ), - focus: ( - background-color: $devui-contrast-hover - ), - active: ( - background-color: $devui-contrast-active - ) - ) -); - .devui-btn { - padding: $devui-btn-padding; - font-size: $devui-btn-font-size; - height: $devui-btn-height; - line-height: $devui-btn-line-height; + padding: 0 20px; + font-size: $devui-font-size-md; + height: 28px; + line-height: $devui-line-height-base; + border: none; border-radius: $devui-border-radius; border-width: 1px; - border-color: transparent; background-color: transparent; - @each $type in text, text-dark, common, stress, primary, danger, success, warning, left, right, xs, sm, lg { - &.devui-btn-#{$type} { - @each $key, $value in map-get($devui-btn-normal-config, $type) { - #{$key}: $value; - } - } + + &:hover { + cursor: pointer; } - @each $type in text, text-dark, common, stress, primary, danger { - &.devui-btn-#{$type} { - @each $pseudo, $value in map-get($devui-btn-pseudo-config, $type) { - &:#{$pseudo} { - @each $key, $value2 in map-get(map-get($devui-btn-pseudo-config, $type), $pseudo) { - #{$key}: $value2; - } - } + + &:disabled { + cursor: not-allowed; + } + + &-solid { + color: $devui-light-text; + min-width: 64px; + + &-secondary { + color: $devui-text; + background-color: $devui-dividing-line; + + &:hover { + opacity: 0.8; + } + + &:focus { + opacity: 0.8; + } + + &:active { + opacity: 0.8; + } + + &:disabled { + opacity: 0.8; } } - } - &.devui-btn-common { - &:disabled { - color: $devui-disabled-text; - background: $devui-disabled-bg; - border: 1px solid $devui-disabled-line; + &-primary { + background-color: $devui-primary; + + &:hover { + background-color: $devui-primary-hover; + } + + &:focus { + background-color: $devui-primary-hover; + } + + &:active { + background-color: $devui-primary-active; + } + + &:disabled { + color: $devui-light-text; + background: $devui-primary-disabled; + border: none; + } } - } - &.devui-btn-primary { - &:disabled { - color: $devui-light-text; - background: $devui-primary-disabled; - border: none; + &-danger { + background-color: $devui-contrast; + + &:hover { + background-color: $devui-contrast-hover; + } + + &:focus { + background-color: $devui-contrast-hover; + } + + &:active { + background-color: $devui-contrast-active; + } + + &:disabled { + background-color: $devui-contrast-disabled; + } } } - &.devui-btn-danger { - &:disabled { - color: $devui-disabled-text; - background: $devui-disabled-bg; - border: 1px solid $devui-disabled-line; + &-outline { + background-color: $devui-block; + border-style: solid; + + &-secondary { + color: $devui-text; + border-color: $devui-line; + + &:hover { + color: $devui-brand-active; + border-color: $devui-form-control-line-active; + } + + &:focus { + color: $devui-brand-active; + border-color: $devui-form-control-line-active; + } + + &:active { + color: $devui-brand-active; + border-color: $devui-form-control-line-active; + } + + &:disabled { + color: $devui-disabled-text; + border-color: $devui-disabled-line; + background-color: $devui-disabled-bg; + } } - } - &.devui-btn-text-dark { - &:disabled { - color: $devui-disabled-text; + &-primary { + color: $devui-brand-active; + border-color: $devui-form-control-line-active; + + &:hover { + color: $devui-brand-active-focus; + border-color: $devui-form-control-line-active-hover; + } + + &:focus { + color: $devui-brand-active-focus; + border-color: $devui-form-control-line-active-hover; + } + + &:active { + color: $devui-brand-active-focus; + border-color: $devui-form-control-line-active-hover; + } + + &:disabled { + opacity: 0.8; + } } - } - &.devui-btn-text { - &:disabled { - color: $devui-disabled-text; + &-danger { + color: $devui-contrast; + border-color: $devui-contrast; + + &:hover, + &:focus, + &:active, + &:disabled { + opacity: 0.8; + } } } - &.bordered { - &.devui-btn-common { + &-text { + padding: 0; + + &-secondary { color: $devui-text; - border-color: transparent; - &:hover, + &:hover { + color: $devui-list-item-hover-text; + } + &:focus { - border-color: $devui-primary-hover; + color: $devui-list-item-hover-text; } &:active { - border-color: $devui-primary-active; + color: $devui-list-item-hover-text; } - } - &.devui-btn-stress, - &.devui-btn-primary { - border-color: $devui-brand; - color: $devui-brand; - background-color: $devui-block; + &:disabled { + color: $devui-disabled-text; + } } - &.devui-btn-success { - border-color: $devui-success; - color: $devui-success; - background-color: $devui-block; - } + &-primary { + color: $devui-brand-active; + + &:hover { + color: $devui-brand-active-focus; + } - &.devui-btn-warning { - border-color: $devui-warning; - color: $devui-warning; - background-color: $devui-block; + &:focus { + color: $devui-brand-active-focus; + } + + &:active { + color: $devui-brand-active-focus; + } + + &:disabled { + opacity: 0.8; + } } - &.devui-btn-danger { - border-color: $devui-danger; - color: $devui-danger; - background-color: $devui-block; + &-danger { + color: $devui-contrast; + + &:hover, + &:focus, + &:active, + &:disabled { + opacity: 0.8; + } } } - &.d-btn-icon { + &-xs { + padding: 0 4px; + height: 24px; + font-size: $devui-font-size-sm; + min-width: 48px; + } + + &-sm { + padding: 0 16px; + height: 24px; + font-size: $devui-font-size-sm; + min-width: 56px; + } + + &-lg { + padding: 0 24px; + height: 32px; + font-size: $devui-font-size-lg; + min-width: 72px; + } + + &.devui-btn-icon { &:hover, &:focus { border: 1px solid $devui-list-item-hover-bg; @@ -291,9 +246,8 @@ $devui-btn-pseudo-config: ( } &:not(:disabled) { - &.devui-btn-text, - &.devui-btn-text-dark { - &.d-btn-icon-wrap { + &.devui-btn-text { + &.devui-btn-icon-wrap { color: $devui-text; .devui-icon-fix { @@ -320,39 +274,29 @@ $devui-btn-pseudo-config: ( } } -.devui-btn-host { - display: inline-block; -} - .devui-btn { transition: background-color 0.2s; - border-style: solid; white-space: nowrap; - display: flex; + display: inline-flex; align-items: center; justify-content: center; - &:disabled, - &[disabled] { - cursor: not-allowed; - } - - &.d-btn-icon { - padding: 7px 8px; + &.devui-btn-icon { + padding: 8px; line-height: 1em; border: 1px solid transparent; &.devui-btn-xs, &.devui-btn-sm { - padding: 5px; + padding: 4px; } &.devui-btn-xs { - min-width: $devui-btn-icon-xs-min-width; + min-width: 24px; } &.devui-btn-sm { - min-width: $devui-btn-icon-sm-min-width; + min-width: 24px; } &.devui-btn-lg { @@ -360,11 +304,11 @@ $devui-btn-pseudo-config: ( font-size: $devui-font-size-icon; } - min-width: $devui-btn-icon-lg-min-width; + min-width: 32px; } } - &:not(.d-btn-icon) { + &:not(.devui-btn-icon) { .icon-fix { font-size: $devui-font-size-icon; } @@ -376,20 +320,6 @@ $devui-btn-pseudo-config: ( font-size: $devui-font-size; } -.devui-btn.devui-btn-common:not(:disabled) { - .devui-icon-fix { - color: $devui-text-weak; - } - - &:hover, - &:active, - &:focus { - .devui-icon-fix { - color: $devui-icon-fill-active; - } - } -} - .button-content { display: inline-block; position: relative; diff --git a/packages/devui-vue/devui/button/src/button.tsx b/packages/devui-vue/devui/button/src/button.tsx index 554a99b2a6..58e50f20a8 100644 --- a/packages/devui-vue/devui/button/src/button.tsx +++ b/packages/devui-vue/devui/button/src/button.tsx @@ -1,71 +1,36 @@ -import { computed, defineComponent, ref } from 'vue'; +import { defineComponent, toRefs } from 'vue'; +import type { SetupContext } from 'vue'; import { Icon } from '../../icon'; -import { buttonProps } from './button-types'; - +import loadingDirective from '../../loading/src/directive'; +import { buttonProps, ButtonProps } from './button-types'; +import useButton from './use-button'; import './button.scss'; export default defineComponent({ name: 'DButton', + directives: { + devLoading: loadingDirective, + }, props: buttonProps, - setup(props, ctx) { - const buttonContent = ref(null); + emits: ['click'], + setup(props: ButtonProps, ctx: SetupContext) { + const { icon, disabled, loading } = toRefs(props); + const { classes, iconClass } = useButton(props, ctx); const onClick = (e: MouseEvent) => { - if (props.showLoading) { + if (loading.value) { return; } - props.onClick?.(e); - } - - const hasContent = computed(() => ctx.slots.default); - - const btnClass = computed(() => { - const { btnStyle, size, position, bordered, icon } = props; - const origin = `devui-btn devui-btn-${btnStyle} devui-btn-${size} devui-btn-${position}`; - const borderedClass = bordered ? 'bordered' : ''; - const btnIcon = !!icon && !hasContent.value && btnStyle !== 'primary' ? 'd-btn-icon' : ''; - const btnIconWrap = !!icon ? 'd-btn-icon-wrap' : ''; - return `${origin} ${borderedClass} ${btnIcon} ${btnIconWrap}`; - }); - - const iconClass = computed(() => { - if (!props.icon) { - return; - } - const origin = 'devui-icon-fix icon'; - if (hasContent.value) { - return `${origin} clear-right-5`; - } else { - return origin; - } - }); + ctx.emit('click', e); + }; return () => { - const { - icon, - type, - disabled, - showLoading, - width - } = props; return ( -
    - -
    + ); - } - } + }; + }, }); diff --git a/packages/devui-vue/devui/button/src/use-button.ts b/packages/devui-vue/devui/button/src/use-button.ts new file mode 100644 index 0000000000..a27c458832 --- /dev/null +++ b/packages/devui-vue/devui/button/src/use-button.ts @@ -0,0 +1,35 @@ +import { computed } from 'vue'; +import type { SetupContext } from 'vue'; +import { ButtonProps, UseButtonReturnType } from './button-types'; + +export default function useButton(props: ButtonProps, ctx: SetupContext): UseButtonReturnType { + const hasContent = computed(() => ctx.slots.default); + const colorMap = { + solid: 'primary', + outline: 'secondary', + text: 'secondary', + }; + const defaultColor = colorMap[props.variant]; + + const classes = computed(() => ({ + 'devui-btn': true, + [`devui-btn-${props.variant}`]: true, + [`devui-btn-${props.variant}-${props.color || defaultColor}`]: true, + [`devui-btn-${props.size}`]: true, + 'devui-btn-icon-wrap': props.icon, + 'devui-btn-icon': props.icon && !hasContent.value && props.variant !== 'solid', + })); + const iconClass = computed(() => { + if (!props.icon) { + return; + } + const origin = 'devui-icon-fix icon'; + if (hasContent.value) { + return `${origin} clear-right-5`; + } else { + return origin; + } + }); + + return { classes, iconClass }; +} diff --git a/packages/devui-vue/devui/card/__tests__/__snapshots__/card.spec.ts.snap b/packages/devui-vue/devui/card/__tests__/__snapshots__/card.spec.ts.snap new file mode 100644 index 0000000000..76e8d750da --- /dev/null +++ b/packages/devui-vue/devui/card/__tests__/__snapshots__/card.spec.ts.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`card should render correctly 1`] = ` +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    + +
    +
    + +
    +
    +`; diff --git a/packages/devui-vue/devui/carousel/__tests__/carousel.spec.ts b/packages/devui-vue/devui/carousel/__tests__/carousel.spec.ts index 525d71d769..a900c84833 100644 --- a/packages/devui-vue/devui/carousel/__tests__/carousel.spec.ts +++ b/packages/devui-vue/devui/carousel/__tests__/carousel.spec.ts @@ -1,8 +1,7 @@ import { ref, nextTick } from 'vue' import { mount } from '@vue/test-utils'; -import Carousel from '../carousel'; -import CarouselItem from '../item'; -import Button from '../../button' +import { CarouselItem, Carousel } from '../index'; +import { Button } from '../../button' const wait = (ms = 100) => new Promise(resolve => setTimeout(() => resolve(), ms)) @@ -140,13 +139,13 @@ describe('d-carousel', () => { {{ item }} `, setup() { - const items = ref(["page 1", 'page 2', 'page 3', 'page 4']) + const items = ref(['page 1', 'page 2', 'page 3', 'page 4']) const activeIndex = ref(0) const carousel = ref() diff --git a/packages/devui-vue/devui/carousel/index.ts b/packages/devui-vue/devui/carousel/index.ts index 792419a4fc..6f05109ddf 100644 --- a/packages/devui-vue/devui/carousel/index.ts +++ b/packages/devui-vue/devui/carousel/index.ts @@ -1,16 +1,17 @@ import type { App } from 'vue' import Carousel from './src/carousel' -import CarouseItem from './src/item' +import CarouselItem from './src/item' Carousel.install = function(app: App) { app.component(Carousel.name, Carousel) } -CarouseItem.install = function(app: App) { - app.component(CarouseItem.name, CarouseItem); +CarouselItem.install = function(app: App) { + app.component(CarouselItem.name, CarouselItem); } export { Carousel } +export { CarouselItem } export default { title: 'Carousel 走马灯', @@ -18,6 +19,6 @@ export default { status: '80%', install(app: App): void { app.use(Carousel as any) - app.use(CarouseItem as any) + app.use(CarouselItem as any) } } diff --git a/packages/devui-vue/devui/cascader/components/cascader-item/index.scss b/packages/devui-vue/devui/cascader/components/cascader-item/index.scss index f3368ead25..35161178bf 100644 --- a/packages/devui-vue/devui/cascader/components/cascader-item/index.scss +++ b/packages/devui-vue/devui/cascader/components/cascader-item/index.scss @@ -9,15 +9,18 @@ color: $devui-text; cursor: pointer; @include flex(flex-start); + -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } + .cascader-li__wraper { flex: 1; @include flex(flex-start); } + .dropdown-item-label { display: inline-block; flex: 1; @@ -49,6 +52,7 @@ margin-right: 4px; font-size: $devui-font-size-icon; color: $devui-text; + &.disabled { color: $devui-disabled-text !important; } diff --git a/packages/devui-vue/devui/cascader/components/cascader-multiple/index.scss b/packages/devui-vue/devui/cascader/components/cascader-multiple/index.scss index 848f54b3c6..315baf646f 100644 --- a/packages/devui-vue/devui/cascader/components/cascader-multiple/index.scss +++ b/packages/devui-vue/devui/cascader/components/cascader-multiple/index.scss @@ -1,6 +1,7 @@ @import '../../../style/theme/color'; @import '../../../style/theme/corner'; @import '../../../style/core/font'; + .devui-tags { &-input { flex: 1; @@ -11,6 +12,7 @@ background-color: $devui-base-bg; transition: border-color 300ms cubic-bezier(0.645, 0.045, 0.355, 1); } + &-box { width: 100%; overflow: auto; @@ -20,10 +22,11 @@ align-items: center; flex-wrap: wrap; } + &-placeholder { font-size: $devui-font-size; line-height: 22px; margin-left: 6px; color: $devui-placeholder; } -} \ No newline at end of file +} diff --git a/packages/devui-vue/devui/cascader/components/cascader-tag/index.scss b/packages/devui-vue/devui/cascader/components/cascader-tag/index.scss index 2e57f9bc2f..71dd8cb947 100644 --- a/packages/devui-vue/devui/cascader/components/cascader-tag/index.scss +++ b/packages/devui-vue/devui/cascader/components/cascader-tag/index.scss @@ -1,6 +1,7 @@ @import '../../../style/theme/color'; @import '../../../style/theme/corner'; @import '../../../style/core/font'; + .devui-tag { margin: 2px 4px 2px 0; display: inline-block; @@ -12,6 +13,7 @@ border-radius: $devui-border-radius; border-color: inherit; border: 0 solid; + span { min-height: 20px; line-height: 20px; @@ -20,11 +22,12 @@ position: relative; cursor: default; } + &__close { margin-left: 12px; - font-size: $devui-font-size; + font-size: $devui-font-size; cursor: pointer; - color: #fff; + color: #ffffff; width: 14px; height: 14px; line-height: 14px; @@ -32,8 +35,9 @@ border-radius: 50%; display: inline-block; text-align: center; + &:hover { background-color: $devui-brand; } } -} \ No newline at end of file +} diff --git a/packages/devui-vue/devui/cascader/hooks/use-cascader-item.ts b/packages/devui-vue/devui/cascader/hooks/use-cascader-item.ts index 603d7b4a09..de4a70fbe0 100644 --- a/packages/devui-vue/devui/cascader/hooks/use-cascader-item.ts +++ b/packages/devui-vue/devui/cascader/hooks/use-cascader-item.ts @@ -1,7 +1,7 @@ /** * 处理cascader-item中需要的参数 */ -import { cloneDeep } from 'lodash-es' +import { cloneDeep } from 'lodash' import { ref, reactive, Ref } from 'vue' import { CascaderProps, UseCascaderItemCallback, CascaderItem } from '../src/cascader-types' diff --git a/packages/devui-vue/devui/cascader/src/cascader.tsx b/packages/devui-vue/devui/cascader/src/cascader.tsx index a0e87f10ed..4ff039a2ea 100644 --- a/packages/devui-vue/devui/cascader/src/cascader.tsx +++ b/packages/devui-vue/devui/cascader/src/cascader.tsx @@ -1,5 +1,5 @@ // 公共库 -import { cloneDeep } from 'lodash-es' +import { cloneDeep } from 'lodash' import { defineComponent, ref, Ref, reactive, watch, toRef } from 'vue' // 组件 diff --git a/packages/devui-vue/devui/color-picker/__tests__/color-picker.spec.ts b/packages/devui-vue/devui/color-picker/__tests__/color-picker.spec.ts new file mode 100644 index 0000000000..a4cd0eb1f6 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/__tests__/color-picker.spec.ts @@ -0,0 +1,8 @@ +import { mount } from '@vue/test-utils'; +import { ColorPicker } from '../index'; + +describe('color-picker test', () => { + it('color-picker init render', async () => { + // todo + }) +}) diff --git a/packages/devui-vue/devui/color-picker/index.ts b/packages/devui-vue/devui/color-picker/index.ts new file mode 100644 index 0000000000..0b15cd3019 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +import ColorPicker from './src/color-picker' + +ColorPicker.install = function (app: App): void { + app.component(ColorPicker.name, ColorPicker) +} + +export { ColorPicker } + +export default { + title: 'ColorPicker 颜色选择器', + category: '数据录入', + status: '80%', // TODO: 组件若开发完成则填入"100%",并删除该注释 + install(app: App): void { + app.use(ColorPicker as any) + } +} diff --git a/packages/devui-vue/devui/color-picker/src/color-picker-types.ts b/packages/devui-vue/devui/color-picker/src/color-picker-types.ts new file mode 100644 index 0000000000..7d6c4164bf --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/color-picker-types.ts @@ -0,0 +1,26 @@ +import type { PropType, ExtractPropTypes } from 'vue' +export const colorPickerProps = { + modelValue: { + type: [Object, String] as PropType + }, + mode: { + type: String + }, + showAlpha: { + type: Boolean, + default: true + }, + dotSize: { + type: Number, + default: 15 + }, + swatches: { + type: Array as PropType + }, + showHistory: { + type: Boolean, + default: true + } +} as const + +export type ColorPickerProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/color-picker.scss b/packages/devui-vue/devui/color-picker/src/color-picker.scss new file mode 100644 index 0000000000..1e51158300 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/color-picker.scss @@ -0,0 +1,83 @@ +@import '../../style/theme/color'; + +.devui-color-picker { + position: relative; + + &-position { + position: absolute; + z-index: 9999; + background-color: $devui-connected-overlay-bg; + top: 0; + } + + &-color-value { + display: flex; + position: absolute; + z-index: 4; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-weight: bold; + color: rgb(204, 15, 15); + } + + &-container { + padding: 3px; + border: 1px solid rgb(224, 224, 230); + border-radius: 3px; + + &-wrap { + width: 100%; + height: 26px; + box-sizing: content-box; + box-shadow: 3px 0 5px #00000014; + position: relative; + cursor: pointer; + overflow: hidden; + display: inline-block; + vertical-align: middle; + + &-current-color { + top: 0; + right: 0; + left: 0; + position: absolute; + z-index: 3; + width: 100%; + height: 100%; + } + + &-current-color-transparent { + top: 0; + right: 0; + left: 0; + overflow: hidden; + padding: 3px; + width: 100%; + height: 100%; + position: absolute; + z-index: 2; + } + + &-transparent { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); + background-repeat: repeat; + } + } + } +} + +.color-picker-transition-enter-from, +.color-picker-transition-leave-to { + opacity: 0; +} + +.color-picker-transition-enter-to, +.color-picker-transition-leave-from { + opacity: 1; +} + +.color-picker-transition-enter-active, +.color-picker-transition-leave-active { + transition: opacity 0.2s ease-in-out; +} diff --git a/packages/devui-vue/devui/color-picker/src/color-picker.tsx b/packages/devui-vue/devui/color-picker/src/color-picker.tsx new file mode 100644 index 0000000000..a3c91a2132 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/color-picker.tsx @@ -0,0 +1,178 @@ +import { + defineComponent, + ref, + computed, + onMounted, + watch, + nextTick, + provide, + Teleport, + unref, + readonly, + Transition +} from 'vue' +import { + useReactive, + colorPickerResize, + isExhibitionColorPicker, + changeColorValue +} from './utils/composeable' +import { colorPickerProps, ColorPickerProps } from './color-picker-types' +import colorPanel from './components/color-picker-panel/color-picker-panel' +import './color-picker.scss' +import { parseColor, extractColor, RGBAtoCSS } from './utils/color-utils' +import { ColorPickerColor } from './utils/color-utils-types' +export default defineComponent({ + name: 'DColorPicker', + components: { + colorPanel + }, + props: colorPickerProps, + emits: ['update:modelValue'], + setup(props: ColorPickerProps, { emit }) { + const DEFAUTL_MODE = 'rgb' + const provideData = { + showAlpha: useReactive(() => props.showAlpha), + swatches: useReactive(() => props.swatches), + dotSize: useReactive(() => props.dotSize), + showHistory: useReactive(() => props.showHistory) + } + provide('provideData', readonly(provideData)) + const initialColor = ref(null) + const colorCubeRef = ref() + const pickerRef = ref() + const containerRef = ref() + const left = ref(0) + const top = ref(0) + const isChangeTextColor = ref(true) + const showColorPicker = ref(false) + const formItemText = ref(`${props.mode ?? DEFAUTL_MODE}`) + const mode = ref(unref(props.mode)) + onMounted(() => { + // resize 响应式 colorpicker + window.addEventListener('resize', resize) + // 点击展示 colorpicker + window.addEventListener('click', isExhibition) + }) + // ** computeds + // colorpicker panel 组件位置 + const colorPickerPostion = computed(() => { + if (colorCubeRef.value) { + return { + transform: `translate(${left.value}px, ${top.value}px)` + } + } + return null + }) + // 交互触发item 颜色 面板 动态修改alpha后要还原 alpha 2021.12.18 + const tiggerColor = computed(() => { + const currentColor = initialColor.value.rgba + const trigger = { ...currentColor, a: props.showAlpha ? currentColor.a : 1 } + return { + backgroundColor: `${RGBAtoCSS(trigger)}` + } + }) + // 交互面板 的value 值 动态展示 根据不同 type + const formItemValue = computed(() => { + return extractColor(initialColor.value, '', formItemText.value, props.showAlpha) + }) + // 动态 根据当前 透明度修改文本颜色 tips:根据不同 面板颜色 目前 不够优雅 + const textColor = computed(() => { + // 数字代表 hsv 中的value 值 纵轴 动态切换 文本颜色 + return changeColorValue(initialColor.value, 0.5) + }) + // ** emits + // 动态 交互面板 文本展示颜色 tips:根据不同 面板颜色 目前 不够优雅 + function changeTextColor(value: boolean): void { + isChangeTextColor.value = value + } + // 通过修改画板 颜色 修改 v-model 颜色 + function changePaletteColor(colorMap: ColorPickerColor): void { + updateUserColor(colorMap) + } + // 通过用户点击触发修改 交互面板 文本类型 + function changeTextModeType(type: string): void { + mode.value = type + formItemText.value = type + } + + // 初始化的时候 确定 colopicker位置 由于 pickerref 默认 为 undefined 所以监听 showcolorpicker + watch( + () => showColorPicker.value, + (newValue) => { + const textPalette = colorCubeRef.value?.getBoundingClientRect() + newValue && + nextTick(() => { + pickerRef.value.style.transform = `translate(${textPalette.left + 'px'}, ${ + textPalette.top + window.scrollY + textPalette.height + 'px' + })` + }) + } + ) + // 监听用户输入 2021.12.10 + watch( + () => props.modelValue, + (newValue) => { + // 全部转换成对象 + updateUserColor(parseColor(newValue, initialColor.value)) + }, + { immediate: true } + ) + // 更新用户输入颜色 2021.12.10 + function updateUserColor(color) { + initialColor.value = color + // 提取颜色 2021.12.10 + const value = extractColor(initialColor.value, props.modelValue, mode.value, props.showAlpha) + emit('update:modelValue', value) + } + function resize() { + return colorPickerResize(colorCubeRef, top, left) + } + function isExhibition(event: Event) { + return isExhibitionColorPicker(event, colorCubeRef, pickerRef, showColorPicker) + } + return () => { + return ( +
    +
    +
    +
    +
    +
    +

    {formItemValue.value}

    +
    +
    +
    + + + {showColorPicker.value ? ( +
    + +
    + ) : null} +
    +
    +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-alpha-slider.scss b/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-alpha-slider.scss new file mode 100644 index 0000000000..b624a0ea53 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-alpha-slider.scss @@ -0,0 +1,42 @@ +.devui-color-picker-alpha-slider { + position: relative; + margin-bottom: 15px; + width: 100%; + height: 14px; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08); + border-radius: 15px; + + &.transparent { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); + background-repeat: repeat; + } + + &__bar { + position: relative; + width: 100%; + height: 100%; + border-radius: 15px; + + &-pointer { + position: absolute; + width: 14px; + height: 14px; + } + + &-handle { + width: 14px; + height: 14px; + border-radius: 6px; + transform: translate(-7px, -2px); + background-color: #f8f8f8; + margin-top: 2px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37); + cursor: pointer; + + &.vertical { + transform: translate(0, -7px); + margin-top: 0; + } + } + } +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-alpha-slider.tsx b/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-alpha-slider.tsx new file mode 100644 index 0000000000..3df8c6c99e --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-alpha-slider.tsx @@ -0,0 +1,98 @@ +import { computed, defineComponent, ref, onMounted } from 'vue' +import { colorPickerAlphaSliderProps } from './color-picker-alpha-slider-types' +import { DOMUtils } from '../../utils/domDragger' +import { RGBtoCSS, fromHSVA } from '../../utils/color-utils' +import './color-alpha-slider.scss' +export default defineComponent({ + name: 'ColorAlphaSlider', + props: colorPickerAlphaSliderProps, + emits: ['update:modelValue'], + setup(props: colorPickerAlphaSliderProps, ctx) { + const DEFAULT_TRANSITION = { transition: 'all 0.3s ease' } + const clickTransfrom = ref(DEFAULT_TRANSITION) + const barElement = ref(null) + const cursorElement = ref(null) + const onClickSider = (event: Event) => { + const target = event.target + if (target !== barElement.value) { + onMoveBar(event as MouseEvent) + } + } + + const onMoveBar = (event: MouseEvent) => { + event.stopPropagation() + if (barElement.value && cursorElement.value) { + const rect = barElement.value.getBoundingClientRect() + const offsetWidth = cursorElement.value.offsetWidth + let left = event.clientX - rect.left + left = Math.max(offsetWidth / 2, left) + left = Math.min(left, rect.width - offsetWidth / 2) + const alpha = Math.round(((left - offsetWidth / 2) / (rect.width - offsetWidth)) * 100) + ctx.emit('update:modelValue', fromHSVA({ ...props.modelValue.hsva, a: alpha / 100 })) + } + } + + const getBackgroundStyle = computed(() => { + return { + background: `linear-gradient(to right, transparent , ${RGBtoCSS(props.modelValue.rgba)})` + } + }) + + const getCursorLeft = computed(() => { + if (barElement.value && cursorElement.value) { + const alpha = props.modelValue.rgba.a + const rect = barElement.value.getBoundingClientRect() + const offsetWidth = cursorElement.value.offsetWidth + return Math.round(alpha * (rect.width - offsetWidth) + offsetWidth / 2) + } + return 0 + }) + + const getCursorStyle = computed(() => { + const left = getCursorLeft.value + return { + left: left + 'px', + top: 0, + ...clickTransfrom.value + } + }) + onMounted(() => { + const dragConfig = { + drag: (event: Event) => { + clickTransfrom.value = null + onMoveBar(event as MouseEvent) + }, + end: (event: Event) => { + clickTransfrom.value = DEFAULT_TRANSITION + onMoveBar(event as MouseEvent) + } + } + if (barElement.value && cursorElement.value) { + DOMUtils.triggerDragEvent(barElement.value, dragConfig) + } + }) + const alphaClass = computed(() => { + return ['devui-color-picker-alpha-slider', 'transparent'] + }) + return () => { + return ( +
    +
    +
    +
    +
    +
    +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-picker-alpha-slider-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-picker-alpha-slider-types.ts new file mode 100644 index 0000000000..51f23d935d --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-alpha-slider/color-picker-alpha-slider-types.ts @@ -0,0 +1,25 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import type { ColorPickerColor } from '../../utils/color-utils-types' + +export const colorPickerAlphaSliderProps = { + color: { + type: Object + }, + modelValue: { + type: Object as PropType + }, + rgba: { + type: Object, + default: null + }, + height: { + type: Number, + default: 15 + }, + width: { + type: Number, + default: 300 + } +} as const + +export type colorPickerAlphaSliderProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic-types.ts new file mode 100644 index 0000000000..b719464714 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic-types.ts @@ -0,0 +1,10 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import type { ColorPickerColor } from '../../utils/color-utils-types' + +export const colorPickerBasicColorProps = { + color: { + type: Object as PropType + } +} as const + +export type colorPickerBasicColorProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic.scss b/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic.scss new file mode 100644 index 0000000000..d3edefbe35 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic.scss @@ -0,0 +1,15 @@ +.devui-color-picker-basic { + flex-wrap: wrap; + // justify-content: space-between; + align-items: center; + + &-div { + box-sizing: content-box; + margin: 4px; + width: 20px; + height: 20px; + border: solid 1px #e7e7e7; + cursor: pointer; + border-radius: 1px; + } +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic.tsx b/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic.tsx new file mode 100644 index 0000000000..3117b00cb1 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-basic/color-basic.tsx @@ -0,0 +1,32 @@ +import { defineComponent, ref, inject } from 'vue' +import { colorPickerBasicColorProps } from './color-basic-types' +import { provideColorOptions } from '../../utils/color-utils-types' +import { fromHex } from '../../utils/color-utils' +import './color-basic.scss' +import { color } from '../../utils/color' +export default defineComponent({ + name: 'ColorBasic', + props: colorPickerBasicColorProps, + setup(props) { + const swatchesInject: provideColorOptions = inject('provideData') + + const currentColor = ref(props.color) + function changeBasicColor(hex: string) { + currentColor.value = fromHex(hex) + } + const swatches = ref(Object.values(swatchesInject.swatches ?? [])) + return () => { + return ( +
    + {(swatches.value.length !== 0 ? swatches.value : color).map((hex: string) => ( +
    changeBasicColor(hex)} + class={['devui-color-picker-basic-div']} + style={{ backgroundColor: hex }} + >
    + ))} +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-edit/color-edit.scss b/packages/devui-vue/devui/color-picker/src/components/color-edit/color-edit.scss new file mode 100644 index 0000000000..5240bb27bb --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-edit/color-edit.scss @@ -0,0 +1,65 @@ +.devui-color-picker-edit { + justify-content: space-between; + align-items: center; + + &-text { + display: inline-block; + width: 50px; + padding: 0 10px; + user-select: none; + } + + &-name { + user-select: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-around; + width: 50px; + } + + &-flex { + display: flex; + align-items: center; + } + + &-input { + flex: 1; + padding: 0 5px; + + input { + border: none; + outline: none; + width: 100%; + text-align: center; + } + + &-wrapper { + width: 100%; + border: 1px solid rgba(0, 0, 0, 0.2); + padding: 2px; + + &:focus-within { + border: 1px solid var(--devui-primary); + } + } + + &.string-input &-wrapper { + border-radius: 5px; + } + + &.number-input &-wrapper { + border-radius: 0; + + &:first-child { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + } + + &:last-child { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + } + } + } +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-edit/color-edit.tsx b/packages/devui-vue/devui/color-picker/src/components/color-edit/color-edit.tsx new file mode 100644 index 0000000000..baa4b92bc5 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-edit/color-edit.tsx @@ -0,0 +1,287 @@ +import { defineComponent, computed, ref, inject } from 'vue' +import { ColorPickerEditProps, colorPickerEditProps } from './color-picker-edit-types' +import { provideColorOptions, ColorPickerColor } from '../../utils/color-utils-types' +import './color-edit.scss' +import { fromHex, fromHexa, fromHSLA, fromHSVA, fromRGBA } from '../../utils/color-utils' +import Schema, { Rules } from 'async-validator' +// 默认 mode +const DEFAUTL_MODE = 'rgb' + +// MODE支持模式 +const MODE_SUPPORT = ['rgb', 'hex', 'hsl', 'hsv'] as const + +// 色值校验规则 +const colorRules: Rules = { + alpha: [ + { + type: 'number', + required: true, + min: 0, + max: 1 + } + ], + hex: [{ type: 'string', pattern: /^#[0-9a-fA-F]{6}/ }], + hexa: [{ type: 'string', pattern: /^#[0-9a-fA-F]{6,8}/ }], + rgba: { + type: 'object', + required: true, + fields: { + r: { type: 'number', required: true, min: 0, max: 255 }, + g: { type: 'number', required: true, min: 0, max: 255 }, + b: { type: 'number', required: true, min: 0, max: 255 }, + a: { type: 'number', required: true, min: 0, max: 1 } + } + }, + hsla: { + type: 'object', + required: true, + fields: { + h: { type: 'number', required: true, min: 0, max: 360 }, + s: { type: 'number', required: true, min: 0, max: 1 }, + l: { type: 'number', required: true, min: 0, max: 1 }, + a: { type: 'number', required: true, min: 0, max: 1 } + } + }, + hsva: { + type: 'object', + required: true, + fields: { + h: { type: 'number', required: true, min: 0, max: 360 }, + s: { type: 'number', required: true, min: 0, max: 1 }, + v: { type: 'number', required: true, min: 0, max: 1 }, + a: { type: 'number', required: true, min: 0, max: 1 } + } + } +} + +export default defineComponent({ + name: 'ColorEdit', + props: colorPickerEditProps, + emits: ['changeTextModeColor', 'update:modelValue'], + setup(props: ColorPickerEditProps, { emit }) { + // 设置showalpha 为false 会报错 2021.12.14 + const isShowAlpha: provideColorOptions = inject('provideData') + // 模式值 + const modelValue = computed( + () => `${props.mode ?? DEFAUTL_MODE}${isShowAlpha.showAlpha ? 'a' : ''}` + ) + // 颜色值 + const colorValue = ref(props.color) + // 模式值类型 + const modelValueType = computed(() => + (props.mode ?? DEFAUTL_MODE) === 'hex' ? 'string' : 'number' + ) + + /** + * 获取有效颜色值 + * @param color + * @returns + */ + function getValidColor(color: ColorPickerColor) { + const validator = new Schema(colorRules) + + // 使用ColorRules验证有效性 + return new Promise((resolve, reject) => { + validator.validate(color, (errors) => { + errors && console.warn('色值校验异常:', errors) + errors ? reject() : resolve(color) + }) + }) + } + + /** + * 修改Mode值 + */ + function onChangeModel() { + // 安装MODE_SUPPORT列表进行更换 + const currentIndex = MODE_SUPPORT.findIndex((x) => x === props.mode ?? DEFAUTL_MODE) + const mode = MODE_SUPPORT[(currentIndex + 1) % MODE_SUPPORT.length] + emit('changeTextModeColor', mode) + } + + /** + * 渲染字符类型组件 + */ + function renderStringValue() { + // 绑定KEy + const key = modelValue.value + const value = colorValue.value[key] + + const getConvertColor = (v: string) => { + // 获取转换函数 + const from = isShowAlpha.showAlpha ? fromHexa : fromHex + + // 获取颜色值 + const color = from(v) + + // 获取色值有效性 + return getValidColor(color) + } + + /** + * 更新输入值 + */ + const updateValue = async (event: Event) => { + const target = event.target as HTMLInputElement + + getConvertColor(target.value) + // 如果Color为有效值则进行更新 + .then((color) => (colorValue.value = color)) + // 如果Color为无效值则还原数值 + .catch(() => (target.value = value)) + } + + return ( +
    +
    + +
    +
    + ) + } + + /** + * 渲染数值类型组件 + * TODO: alpha需要进行白分化显示处理 + * TODO: 监听键盘上下键进行值修改 + */ + function renderNumberValue() { + // 绑定缩放KEYS + const scaleKeys = ['s', 'v', 'l'] as const + const percentKeys = ['a'] as const + + const key = modelValue.value + // 对指定数值进行缩放处理 + const value = colorValue.value[key.replace(/a?$/, 'a')] + + const getConvertColor = (model) => { + // 获取转换函数 + const { from } = [ + { mode: ['rgb', 'rgba'], from: fromRGBA }, + { mode: ['hsv', 'hsva'], from: fromHSVA }, + { mode: ['hsl', 'hsla'], from: fromHSLA } + ].find((x) => x.mode.includes(key)) + // 获取颜色值 + const color = from(isShowAlpha.showAlpha ? model : { ...model, a: 1 }) + + // 通过RGBA进行验证有效性 + return getValidColor(color) + } + + /** + * 更新输入值 + */ + const updateValue = (k: string) => async (event: Event) => { + const target = event.target as HTMLInputElement + + // 获取有效颜色值 + // 无效则进行还原 + // 直接截位取整 + getConvertColor({ ...value, [k]: parseValue(k, target.value) }) + // 如果Color为有效值则进行更新 + .then((color) => (colorValue.value = color)) + // 如果Color为无效值则还原数值 + .catch(() => (target.value = formatValue(k, value[k]))) + } + + /** + * 将存储值转换为显示值 + * @param k + * @param v + * @returns + */ + function formatValue(k, v: number): string { + switch (true) { + case scaleKeys.includes(k): + return (v * 100).toFixed() + case percentKeys.includes(k): + return `${Math.round(v * 100)}%` + default: + return v.toString() + } + } + + /** + * 将显示值转换为存储值 + * @param k + * @param v + * @returns + */ + function parseValue(k, v: string): number { + switch (true) { + case scaleKeys.includes(k): + return parseFloat((parseInt(v) / 100).toFixed(2)) + case percentKeys.includes(k): + return parseFloat((parseInt(v.replace(/%$/, '')) / 100).toFixed(2)) + default: + return Number(v) + } + } + + /** + * 方向键修改值处理 + * @returns + */ + function onKeyChangeValue() { + return (e: KeyboardEvent) => { + const target = e.target as HTMLInputElement + + const changeValue = { + ArrowUp: 1, + ArrowDown: -1 + }[e.code] + + if (changeValue !== undefined) { + e.preventDefault() + const [v] = target.value.match(/\d+/g) + const newValue = (parseInt(v) + changeValue).toString() + target.value = target.value.replace(/\d+/g, newValue) + // 发送数值修改事件 + target.dispatchEvent(new CustomEvent('change')) + } + } + } + + return ( +
    + {key.split('').map((k: string) => ( +
    + +
    + ))} +
    + ) + } + + /** + * 渲染输入组件 + * @returns + */ + function renderValueInput() { + switch (modelValueType.value) { + case 'string': + return renderStringValue() + case 'number': + return renderNumberValue() + } + } + + return () => ( +
    +
    + {modelValue.value.toUpperCase()} +
    + {renderValueInput()} +
    + ) + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-edit/color-picker-edit-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-edit/color-picker-edit-types.ts new file mode 100644 index 0000000000..e7310ebe00 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-edit/color-picker-edit-types.ts @@ -0,0 +1,22 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import { ColorPickerColor } from '../../utils/color-utils-types' +export const colorPickerEditProps = { + /* test: { + type: Object as PropType<{ xxx: xxx }> + } */ + /** + * 选择器圆点大小 + */ + showAlpha: { + type: Boolean, + default: false + }, + mode: { + type: String + }, + color: { + type: Object as PropType + } +} as const + +export type ColorPickerEditProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-history/color-history.scss b/packages/devui-vue/devui/color-picker/src/components/color-history/color-history.scss new file mode 100644 index 0000000000..94b4043e1b --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-history/color-history.scss @@ -0,0 +1,23 @@ +.devui-color-picker-history { + margin-top: 10px; + + &_color-box { + cursor: pointer; + margin: 5px; + height: 20px; + width: 20px; + border: solid 1px #e7e7e7; + + &.transparent { + background: + linear-gradient( + to bottom right, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0) calc(50% - 1.5px), + #f18887 50%, + rgba(0, 0, 0, 0) calc(50% + 1.5px), + rgba(0, 0, 0, 0) 100% + ); + } + } +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-history/color-history.tsx b/packages/devui-vue/devui/color-picker/src/components/color-history/color-history.tsx new file mode 100644 index 0000000000..266b13e8ee --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-history/color-history.tsx @@ -0,0 +1,100 @@ +import { defineComponent, Ref, ref, UnwrapRef, watch, inject } from 'vue' +import { ColorPickerHistoryProps, colorPickerHistoryProps } from './color-picker-history-types' +import { Icon } from '../../../../icon' +import './color-history.scss' +import { fromHexa } from '../../utils/color-utils' +import { provideColorOptions, ColorPickerColor } from '../../utils/color-utils-types' +import { debounce } from 'lodash' + +const STORAGE_KEY = 'STORAGE_COLOR_PICKER_HISTORY_KEY' +const MAX_HISOTRY_COUNT = 8 + +/** + * 创建支持存储Store + * @param v + * @param params + * @returns + */ +function useStore(v: T, { storage }: { storage?: boolean; } = {}): Ref> { + // 获取默认值 + const getDefaultValue = (): T => { + if (storage) { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || v + } else { + return v + } + } + + // 创建Store + const store = ref(getDefaultValue()) + + // 监听Store修改 + watch(store, (value) => { + if (storage) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)) + } + }) + + return store +} + +export default defineComponent({ + name: 'ColorEdit', + components: { + Icon + }, + props: colorPickerHistoryProps, + emits: ['update:color'], + setup(props: ColorPickerHistoryProps) { + // 获取 是否showalpha + const alphaInject: provideColorOptions = inject('provideData') + + // 创建历史存储 + const history = useStore([], { storage: true }) + const color = ref(props.color) + + // 更新历史值函数 + // 进行缓冲处理 + const updateHistory = debounce((value: ColorPickerColor) => { + const index = history.value.findIndex( + (x) => x === value.hexa || (x.endsWith('00') && value.alpha === 0) + ) + if (index >= 0) { + history.value.splice(index, 1) + } + + history.value = [alphaInject.showAlpha ? value.hexa : value.hex, ...history.value].slice( + 0, + MAX_HISOTRY_COUNT + ) + }, 100) + + // 更新历史值 + watch(props.color, (value) => { + updateHistory(value) + }) + + /** + * 选择历史色 + * @param value + */ + function onChangeColor(value: string) { + color.value = fromHexa(value) + } + + return () => ( +
    + {history.value.map((hexa: string) => ( +
    onChangeColor(hexa)} + class={[ + 'devui-color-picker-history_color-box', + hexa.endsWith('00') ? 'transparent' : '' + ]} + style={{ backgroundColor: hexa }} + >
    + ))} +
    + ) + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-history/color-picker-history-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-history/color-picker-history-types.ts new file mode 100644 index 0000000000..454b44d98d --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-history/color-picker-history-types.ts @@ -0,0 +1,10 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import type { ColorPickerColor } from '../../utils/color-utils-types' + +export const colorPickerHistoryProps = { + color: { + type: Object as PropType + } +} as const + +export type ColorPickerHistoryProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-hue-slider.scss b/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-hue-slider.scss new file mode 100644 index 0000000000..42c760fe10 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-hue-slider.scss @@ -0,0 +1,75 @@ +.devui-color-picker-hue-slider { + position: relative; + margin: 13px 0; + width: 100%; + height: 14px; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08); + border-radius: 15px; + + &.transparent { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==); + background-repeat: repeat; + } + + &__bar { + position: relative; + width: 100%; + height: 100%; + border-radius: 15px; + background: + -webkit-linear-gradient( + left, + rgb(255, 0, 0) 0%, + rgb(255, 255, 0) 16.66%, + rgb(0, 255, 0) 33.33%, + rgb(0, 255, 255) 50%, + rgb(0, 0, 255) 66.66%, + rgb(255, 0, 255) 83.33%, + rgb(255, 0, 0) 100% + ); + background: + -moz-linear-gradient( + left, + rgb(255, 0, 0) 0%, + rgb(255, 255, 0) 16.66%, + rgb(0, 255, 0) 33.33%, + rgb(0, 255, 255) 50%, + rgb(0, 0, 255) 66.66%, + rgb(255, 0, 255) 83.33%, + rgb(255, 0, 0) 100% + ); + background: + -ms-linear-gradient( + left, + rgb(255, 0, 0) 0%, + rgb(255, 255, 0) 16.66%, + rgb(0, 255, 0) 33.33%, + rgb(0, 255, 255) 50%, + rgb(0, 0, 255) 66.66%, + rgb(255, 0, 255) 83.33%, + rgb(255, 0, 0) 100% + ); + + &-pointer { + position: absolute; + width: 14px; + height: 14px; + } + + &-handle { + width: 14px; + height: 14px; + border-radius: 6px; + transform: translate(-7px, -2px); + background-color: #f8f8f8; + margin-top: 2px; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37); + cursor: pointer; + + &.vertical { + transform: translate(0, -7px); + margin-top: 0; + } + } + } +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-hue-slider.tsx b/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-hue-slider.tsx new file mode 100644 index 0000000000..7f1e5dcd3b --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-hue-slider.tsx @@ -0,0 +1,95 @@ +import { computed, defineComponent, ref, onMounted, watch } from 'vue' +import { colorPickerHueSliderProps } from './color-picker-hue-slider-types' +import { DOMUtils } from '../../utils/domDragger' +import { fromHSVA } from '../../utils/color-utils' +import './color-hue-slider.scss' +export default defineComponent({ + name: 'ColorHueSlider', + props: colorPickerHueSliderProps, + emits: ['update:modelValue'], + setup(props, ctx) { + const DEFAULT_TRANSITION = { transition: 'all 0.3s ease' } + const barElement = ref(null) + const cursorElement = ref(null) + const clickTransfrom = ref(DEFAULT_TRANSITION) + const getCursorLeft = () => { + if (barElement.value && cursorElement.value) { + const rect = barElement.value.getBoundingClientRect() + const offsetWidth = cursorElement.value.offsetWidth + if (props.modelValue.hue === 360) { + return rect.width - offsetWidth / 2 + } + return ((props.modelValue.hue % 360) * (rect.width - offsetWidth)) / 360 + offsetWidth / 2 + } + return 0 + } + + const getCursorStyle = computed(() => { + const left = getCursorLeft() + return { + left: left + 'px', + top: 0, + ...clickTransfrom.value + } + }) + const onClickSider = (event: Event) => { + const target = event.target + if (target !== barElement.value) { + onMoveBar(event as MouseEvent) + } + } + + const onMoveBar = (event: MouseEvent) => { + event.stopPropagation() + if (barElement.value && cursorElement.value) { + const rect = barElement.value.getBoundingClientRect() + const offsetWidth = cursorElement.value.offsetWidth + let left = event.clientX - rect.left + left = Math.min(left, rect.width - offsetWidth / 2) + left = Math.max(offsetWidth / 2, left) + const hue = Math.round(((left - offsetWidth / 2) / (rect.width - offsetWidth)) * 360) + ctx.emit( + 'update:modelValue', + fromHSVA({ + h: hue, + s: props.modelValue.hsva.s, + v: props.modelValue.hsva.v, + a: props.modelValue.hsva.a + }) + ) + } + } + + onMounted(() => { + const dragConfig = { + drag: (event: Event) => { + clickTransfrom.value = null + onMoveBar(event as MouseEvent) + }, + end: (event: Event) => { + clickTransfrom.value = DEFAULT_TRANSITION + onMoveBar(event as MouseEvent) + } + } + + if (barElement.value && cursorElement.value) { + DOMUtils.triggerDragEvent(barElement.value, dragConfig) + } + }) + return () => { + return ( +
    +
    +
    +
    +
    +
    +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-picker-hue-slider-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-picker-hue-slider-types.ts new file mode 100644 index 0000000000..c4bcd4314a --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-hue-slider/color-picker-hue-slider-types.ts @@ -0,0 +1,27 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import type { ColorPickerColor } from '../../utils/color-utils-types' + +export const colorPickerHueSliderProps = { + /* test: { + type: Object as PropType<{ xxx: xxx }> + } */ + /** + * 选择器圆点大小 + */ + color: { + type: Object + }, + modelValue: { + type: Object as PropType + }, + width: { + type: Number, + default: 300 + }, + height: { + type: Number, + default: 15 + } +} as const + +export type colorPickerHueSliderProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-palette/color-palette.scss b/packages/devui-vue/devui/color-picker/src/components/color-palette/color-palette.scss new file mode 100644 index 0000000000..8204217424 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-palette/color-palette.scss @@ -0,0 +1,34 @@ +.devui-color-picker-palette { + position: relative; + width: 100%; + + &__white, + &__black { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + &__black { + background: linear-gradient(0deg, #000000, transparent); + } + + &__white { + background: linear-gradient(90deg, #ffffff, hsla(0, 0%, 100%, 0)); + } + + &-handler { + position: absolute; + // z-index: 999; + div { + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid #ffffff; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37); + cursor: pointer; + } + } +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-palette/color-palette.tsx b/packages/devui-vue/devui/color-picker/src/components/color-palette/color-palette.tsx new file mode 100644 index 0000000000..882d8f8746 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-palette/color-palette.tsx @@ -0,0 +1,131 @@ +import { defineComponent, ref, onMounted, computed, getCurrentInstance, watch, inject } from 'vue' +import { DOMUtils } from '../../utils/domDragger' +import { fromHSVA } from '../../utils/color-utils' +import { clamp } from '../../utils/helpers' +import { colorPickerPaletteProps } from './color-picker-palette-types' +import { provideColorOptions } from '../../utils/color-utils-types' +import './color-palette.scss' +export default defineComponent({ + name: 'ColorPallete', + props: colorPickerPaletteProps, + emits: ['update:modelValue', 'changeTextColor'], + setup(props: colorPickerPaletteProps, ctx) { + const DEFAULT_TRANSITION = { transition: 'all 0.3s ease' } + const dotSizeInject: provideColorOptions = inject('provideData') + + const clickTransfrom = ref(DEFAULT_TRANSITION) + const paletteElement = ref() + const canvasElement = ref() + const handlerElement = ref() + const paletteInstance = getCurrentInstance() + + const cursorTop = ref(0) + const cursorLeft = ref(0) + const getDotStyle = computed(() => { + return { + width: `${dotSizeInject.dotSize}px`, + height: `${dotSizeInject.dotSize}px`, + transform: `translate(-${dotSizeInject.dotSize / 2}px, -${dotSizeInject.dotSize / 2}px)` + } + }) + const getCursorStyle = computed(() => { + return { + top: cursorTop.value + 'px', + left: cursorLeft.value + 'px', + ...clickTransfrom.value + } + }) + function renderCanvas() { + const canvas = canvasElement.value.getContext('2d') + const parentWidth = paletteElement.value.offsetWidth + canvasElement.value.width = parentWidth + canvasElement.value.height = props.height + const saturationGradient = canvas.createLinearGradient(0, 0, parentWidth, 0) + saturationGradient.addColorStop(0, 'hsla(0, 0%, 100%, 1)') // white + saturationGradient.addColorStop(1, `hsla(${props.modelValue.hue}, 100%, 50%, 1)`) + canvas.fillStyle = saturationGradient + canvas.fillRect(0, 0, parentWidth, props.height) + const valueGradient = canvas.createLinearGradient(0, 0, 0, props.height) + valueGradient.addColorStop(0, 'hsla(0, 0%, 100%, 0)') // transparent + valueGradient.addColorStop(1, 'hsla(0, 0%, 0%, 1)') // black + canvas.fillStyle = valueGradient + canvas.fillRect(0, 0, parentWidth, props.height) + } + function clickPalette(event: Event) { + const target = event.target + if (target !== paletteElement.value) { + handleDrag(event as MouseEvent) + } + } + function updatePosition() { + if (paletteInstance) { + const parentWidth = paletteElement.value.offsetWidth + cursorLeft.value = Number(props.modelValue?.hsva.s) * parentWidth + cursorTop.value = (1 - Number(props.modelValue?.hsva.v)) * props.height + } + } + function handleDrag(event: any) { + const parentWidth = paletteElement.value.offsetWidth + if (paletteInstance) { + const el = canvasElement.value + const rect = el?.getBoundingClientRect() + let left = event.clientX - rect.left + let top = event.clientY - rect.top + left = clamp(left, 0, parentWidth) + top = clamp(top, 0, props.height) + cursorLeft.value = left + cursorTop.value = top + const isChangeTextColor = computed(() => { + if (left > rect.width / 2 || top > rect.height / 2) { + return true + } else { + return false + } + }) + ctx.emit( + 'update:modelValue', + fromHSVA({ + h: props.modelValue.hue, + s: clamp(event.clientX - rect.left, 0, rect.width) / rect.width, + v: 1 - clamp(event.clientY - rect.top, 0, rect.height) / rect.height, + a: props.modelValue.alpha + }) + ) + ctx.emit('changeTextColor', isChangeTextColor.value) + } + } + onMounted(() => { + renderCanvas() + if (paletteInstance && paletteInstance.vnode.el && handlerElement.value) { + DOMUtils.triggerDragEvent(paletteInstance.vnode.el as HTMLElement, { + drag: (event: Event) => { + clickTransfrom.value = null + handleDrag(event as MouseEvent) + }, + end: (event) => { + clickTransfrom.value = DEFAULT_TRANSITION + handleDrag(event as MouseEvent) + } + }) + updatePosition() + } + }) + watch( + () => props.modelValue, + () => { + updatePosition() + } + ) + ctx.expose({ renderCanvas }) + return () => { + return ( +
    + +
    +
    +
    +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/components/color-palette/color-picker-palette-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-palette/color-picker-palette-types.ts new file mode 100644 index 0000000000..577f8a4328 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-palette/color-picker-palette-types.ts @@ -0,0 +1,16 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import { fromRGBA } from '../../utils/color-utils' +import type { ColorPickerColor } from '../../utils/color-utils-types' + +export const colorPickerPaletteProps = { + modelValue: { + type: Object as PropType, + default: () => fromRGBA({ r: 255, g: 0, b: 0, a: 1 }) + }, + height: { + type: Number, + default: 200 + } +} as const + +export type colorPickerPaletteProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel-types.ts b/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel-types.ts new file mode 100644 index 0000000000..f6194b8264 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel-types.ts @@ -0,0 +1,20 @@ +import type { PropType, ExtractPropTypes } from 'vue' +import { ColorPickerColor } from '../../utils/color-utils-types' + +export const colorPickerProps = { + colorMap: { + type: Object + }, + modelValue: { + type: Object as PropType + }, + showAlpha: { + type: Boolean, + default: true + }, + mode: { + type: String + } +} as const + +export type ColorPickerProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel.scss b/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel.scss new file mode 100644 index 0000000000..35a3c23238 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel.scss @@ -0,0 +1,9 @@ +.devui-color-picker-panel { + width: 270px; + padding: 12px; + border-radius: 4px; + box-shadow: + 0 3px 5px -1px rgba(0, 0, 0, 0.1), + 0 5px 8px 0 rgba(0, 0, 0, 0.1), + 0 1px 14px 0 rgba(0, 0, 0, 0.1); +} diff --git a/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel.tsx b/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel.tsx new file mode 100644 index 0000000000..a47afcc54e --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/components/color-picker-panel/color-picker-panel.tsx @@ -0,0 +1,86 @@ +import { defineComponent, ref, watch, nextTick, inject } from 'vue' +import { colorPickerProps, ColorPickerProps } from './color-picker-panel-types' +import { provideColorOptions } from '../../utils/color-utils-types' +import { Tabs } from '../../../../tabs' +import colorPalette from '../color-palette/color-palette' +import colorHueSlider from '../color-hue-slider/color-hue-slider' +import colorAlphaSlider from '../color-alpha-slider/color-alpha-slider' +import colorEdit from '../color-edit/color-edit' +import colorBasic from '../color-basic/color-basic' +import './color-picker-panel.scss' +import colorHistory from '../color-history/color-history' +export default defineComponent({ + name: 'ColorPanel', + components: { + colorPalette, + colorHueSlider, + colorAlphaSlider, + colorEdit, + colorBasic, + Tabs, + colorHistory + }, + props: colorPickerProps, + emits: [ + 'update:modelValue', + 'changeTextColor', + 'changeTiggerColor', + 'changePaletteColor', + 'changeTextModeType' + ], + setup(props: ColorPickerProps, { emit }) { + const injectData: provideColorOptions = inject('provideData') + const paletteElement = ref(null) + const showAlpha = injectData.showAlpha + const tab = ref('basic') + function changeTextColor(isChange: boolean) { + emit('changeTextColor', isChange) + } + function changeTextModeColor(currentType: string) { + emit('changeTextModeType', currentType) + } + + // 画板值 + const paletteColorMap = ref(props.modelValue) + // hue slider 值 + watch( + () => paletteColorMap.value, + (newValue) => { + emit('update:modelValue', newValue) + emit('changePaletteColor', newValue) + nextTick(() => { + paletteElement.value && paletteElement.value.renderCanvas() + }) + } + ) + return () => { + return ( +
    + + + + + + + + + + {showAlpha ? ( + + ) : null} + + {injectData.showHistory ? : null} +
    + ) + } + } +}) diff --git a/packages/devui-vue/devui/color-picker/src/utils/color-utils-types.ts b/packages/devui-vue/devui/color-picker/src/utils/color-utils-types.ts new file mode 100644 index 0000000000..6260930b5a --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/utils/color-utils-types.ts @@ -0,0 +1,42 @@ +import { Ref } from 'vue' + +export type ColorModeType = 'hsl' | 'rgb' | 'hsv' | 'hsv' | 'hex' +export interface provideColorOptions { + mode?: ColorModeType + showAlpha?: boolean + tab?: string + dotSize?: number + swatches?: string[] + showHistory?: boolean +} +export interface CssColorObject { + color?: Hex +} +export interface ColorPickerColor { + alpha: number + hex: Hex + hexa: Hexa + hsla: HSLA + hsva: HSVA + hue: number + rgba: RGBA + hsv?: any + hsl?: any +} +export interface position { + left?: Ref + top?: Ref + right?: Ref + bottom?: Ref +} +// Types +export type ColorInt = number +export type HSV = { h: number; s: number; v: number; } +export type HSVA = HSV & { a: number; } +export type RGB = { r: number; g: number; b: number; } +export type RGBA = RGB & { a: number; } +export type HSL = { h: number; s: number; l: number; } +export type HSLA = HSL & { a: number; } +export type Hex = string +export type Hexa = string +export type Color = string | number | {} diff --git a/packages/devui-vue/devui/color-picker/src/utils/color-utils.ts b/packages/devui-vue/devui/color-picker/src/utils/color-utils.ts new file mode 100644 index 0000000000..eec542f0e1 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/utils/color-utils.ts @@ -0,0 +1,481 @@ +// Utilities +import { ref } from 'vue' +import { chunk, padEnd, has, keepDecimal } from './helpers' +import { + ColorPickerColor, + position, + ColorInt, + HSV, + HSVA, + RGB, + RGBA, + HSL, + HSLA, + Hex, + Hexa, + Color +} from './color-utils-types' +export function isCssColor(color?: string | false): boolean { + return !!color && !!color.match(/^(#|var\(--|(rgb|hsl)a?\()/) +} + +export function colorToInt(color: Color): ColorInt { + let rgb + + if (typeof color === 'number') { + rgb = color + } else if (typeof color === 'string') { + let c = color[0] === '#' ? color.substring(1) : color + if (c.length === 3) { + c = c + .split('') + .map((char) => char + char) + .join('') + } + if (c.length !== 6) { + // consoleWarn(`'${color}' is not a valid rgb color`) + } + rgb = parseInt(c, 16) + } else { + throw new TypeError( + `Colors can only be numbers or strings, recieved ${ + color == null ? color : color.constructor.name + } instead` + ) + } + + if (rgb < 0) { + // consoleWarn(`Colors cannot be negative: '${color}'`) + rgb = 0 + } else if (rgb > 0xffffff || isNaN(rgb)) { + // consoleWarn(`'${color}' is not a valid rgb color`) + rgb = 0xffffff + } + + return rgb +} +export function intToHex(color: ColorInt): string { + let hexColor: string = color.toString(16) + + if (hexColor.length < 6) hexColor = '0'.repeat(6 - hexColor.length) + hexColor + + return '#' + hexColor +} + +export function colorToHex(color: Color): string { + return intToHex(colorToInt(color)) +} + +/** + * Converts HSVA to RGBA. Based on formula from https://en.wikipedia.org/wiki/HSL_and_HSV + * + * @param color HSVA color as an array [0-360, 0-1, 0-1, 0-1] + */ +export function HSVAtoRGBA(hsva: HSVA): RGBA { + const { h, s, v, a } = hsva + const f = (n: number) => { + const k = (n + h / 60) % 6 + return v - v * s * Math.max(Math.min(k, 4 - k, 1), 0) + } + + const rgb = [f(5), f(3), f(1)].map((v) => Math.round(v * 255)) + + return { r: rgb[0], g: rgb[1], b: rgb[2], a } +} + +/** + * Converts RGBA to HSVA. Based on formula from https://en.wikipedia.org/wiki/HSL_and_HSV + * + * @param color RGBA color as an array [0-255, 0-255, 0-255, 0-1] + */ +export function RGBAtoHSVA(rgba: RGBA): HSVA { + if (!rgba) return { h: 0, s: 1, v: 1, a: 1 } + + const r = rgba.r / 255 + const g = rgba.g / 255 + const b = rgba.b / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + + let h = 0 + + if (max !== min) { + if (max === r) { + h = 60 * (0 + (g - b) / (max - min)) + } else if (max === g) { + h = 60 * (2 + (b - r) / (max - min)) + } else if (max === b) { + h = 60 * (4 + (r - g) / (max - min)) + } + } + + if (h < 0) h = h + 360 + + const s = max === 0 ? 0 : (max - min) / max + const hsv = [h, s, max] + + return { h: Math.round(hsv[0]), s: hsv[1], v: hsv[2], a: rgba.a } +} + +export function HSVAtoHSLA(hsva: HSVA): HSLA { + const { h, s, v, a } = hsva + + const l = v - (v * s) / 2 + + const sprime = l === 1 || l === 0 ? 0 : (v - l) / Math.min(l, 1 - l) + + return { h: Math.round(h), s: sprime, l, a } +} + +export function HSLAtoHSVA(hsl: HSLA): HSVA { + const { h, s, l, a } = hsl + + const v = l + s * Math.min(l, 1 - l) + + const sprime = v === 0 ? 0 : 2 - (2 * l) / v + + return { h: Math.round(h), s: sprime, v, a } +} + +export function RGBAtoCSS(rgba: RGBA): string { + return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})` +} + +export function RGBtoCSS(rgba: RGBA): string { + return RGBAtoCSS({ ...rgba, a: 1 }) +} + +export function RGBAtoHex(rgba: RGBA): Hex { + const toHex = (v: number) => { + const h = Math.round(v).toString(16) + return ('00'.substring(0, 2 - h.length) + h).toUpperCase() + } + + return `#${[toHex(rgba.r), toHex(rgba.g), toHex(rgba.b), toHex(Math.round(rgba.a * 255))].join( + '' + )}` +} + +export function HexToRGBA(hex: Hex): RGBA { + const rgba = chunk(hex.slice(1), 2).map((c: string) => parseInt(c, 16)) + + return { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: Math.round((rgba[3] / 255) * 100) / 100 + } +} + +export function HexToHSVA(hex: Hex): HSVA { + const rgb = HexToRGBA(hex) + return RGBAtoHSVA(rgb) +} + +export function HSVAtoHex(hsva: HSVA): Hex { + return RGBAtoHex(HSVAtoRGBA(hsva)) +} + +export function parseHex(hex: string): Hex { + if (hex.startsWith('#')) { + hex = hex.slice(1) + } + + hex = hex.replace(/([^0-9a-f])/gi, 'F') + + if (hex.length === 3 || hex.length === 4) { + hex = hex + .split('') + .map((x) => x + x) + .join('') + } + + if (hex.length === 6) { + hex = padEnd(hex, 8, 'F') + } else { + hex = padEnd(padEnd(hex, 6), 8, 'F') + } + + return `#${hex}`.toUpperCase().substring(0, 9) +} + +export function RGBtoInt(rgba: RGBA): ColorInt { + return (rgba.r << 16) + (rgba.g << 8) + rgba.b +} + +export function fromHSVA(hsva: HSVA): ColorPickerColor { + hsva = { ...hsva } + const hexa = HSVAtoHex(hsva) + const hsla = HSVAtoHSLA(hsva) + const rgba = HSVAtoRGBA(hsva) + return { + alpha: hsva.a, + hex: hexa.substring(0, 7), + hexa, + hsla, + hsva, + hue: hsva.h, + rgba + } +} +export function fromRGBA(rgba: RGBA): ColorPickerColor { + const hsva = RGBAtoHSVA(rgba) + const hexa = RGBAtoHex(rgba) + const hsla = HSVAtoHSLA(hsva) + const hsv = { h: hsva.h, s: hsva.s, v: hsva.v } + const hsl = { h: hsla.h, s: hsla.s, l: hsla.l } + return { + alpha: hsva.a, + hex: hexa.substring(0, 7), + hexa, + hsla, + hsva, + hsv, + hsl, + hue: hsva.h, + rgba + } +} +export function fromHexa(hexa: Hexa): ColorPickerColor { + const hsva = HexToHSVA(hexa) + const hsla = HSVAtoHSLA(hsva) + const rgba = HSVAtoRGBA(hsva) + return { + alpha: hsva.a, + hex: hexa.substring(0, 7), + hexa, + hsla, + hsva, + hue: hsva.h, + rgba + } +} +export function fromHSLA(hsla: HSLA): ColorPickerColor { + const hsva = HSLAtoHSVA(hsla) + const hexa = HSVAtoHex(hsva) + const rgba = HSVAtoRGBA(hsva) + return { + alpha: hsva.a, + hex: hexa.substring(0, 7), + hexa, + hsla, + hsva, + hue: hsva.h, + rgba + } +} +export function fromHex(hex: Hex): ColorPickerColor { + return fromHexa(parseHex(hex)) +} + +export function parseColor(color: Color, oldColor?: ColorPickerColor | null): ColorPickerColor { + if (!color) return fromRGBA({ r: 0, g: 0, b: 0, a: 1 }) + + if (typeof color === 'string') { + if (color.indexOf('#') !== -1) { + // const hex = color.replace('#', '').trim() + // return fromHexa(hex) + } else if (color.indexOf('hsl') !== -1) { + let alpha = null + const parts = color + .replace(/hsla|hsl|\(|\)/gm, '') + .split(/\s|,/g) + .filter((val) => val !== '') + .map((val) => parseFloat(val)) + if (parts.length === 4) { + alpha = parts[3] + } else if (parts.length === 3) { + alpha = 1 + } + return fromHSLA({ h: parts[0], s: parts[1], l: parts[2], a: alpha }) + } else if (color.indexOf('rgb') !== -1) { + let alpha = null + const parts = color + .replace(/rgba|rgb|\(|\)/gm, '') + .split(/\s|,/g) + .filter((val) => val !== '') + .map((val) => parseFloat(val)) + + if (parts.length === 4) { + alpha = parts[3] + } else if (parts.length === 3) { + alpha = 1 + } + return fromRGBA({ r: parts[0], g: parts[1], b: parts[2], a: alpha }) + } else if (color.indexOf('hsv') !== -1) { + let alpha = null + const parts = color + .replace(/hsva|hsv|\(|\)/gm, '') + .split(/\s|,/g) + .filter((val) => val !== '') + .map((val) => parseFloat(val)) + + if (parts.length === 4) { + alpha = parts[3] + } else if (parts.length === 3) { + alpha = 1 + } + return fromHSVA({ h: parts[0], s: parts[1], v: parts[2], a: alpha }) + } + if (color === 'transparent') return fromHexa('#00000000') + const hex = parseHex(color) + if (oldColor && hex === oldColor.hexa) { + return oldColor + } else { + return fromHexa(hex) + } + } + + if (typeof color === 'object') { + if (color.hasOwnProperty('alpha')) return color + + const a = color.hasOwnProperty('a') ? parseFloat(color.a) : 1 + + if (has(color, ['r', 'g', 'b'])) { + if (oldColor && color === oldColor.rgba) return oldColor + else return fromRGBA({ ...color, a }) + } else if (has(color, ['h', 's', 'l'])) { + if (oldColor && color === oldColor.hsla) return oldColor + else return fromHSLA({ ...color, a }) + } else if (has(color, ['h', 's', 'v'])) { + if (oldColor && color === oldColor.hsva) return oldColor + else return fromHSVA({ ...color, a }) + } + } + + return fromRGBA({ r: 255, g: 0, b: 0, a: 1 }) +} + +function stripAlpha(color: Color, stripAlpha: boolean) { + if (stripAlpha) { + const { a, ...rest } = color + + return rest + } + + return color +} + +export function extractColor(color: ColorPickerColor, input: Color, mode, showAlpha: boolean): any { + // 色相 + const hue = keepDecimal(color.hsla.h, 2) + // 饱和度 + const hslSaturation = keepDecimal(color.hsla.s, 2) + // 亮度 + const lightness = keepDecimal(color.hsla.l, 2) + // red + const red = keepDecimal(color.rgba.r) + // green + const green = keepDecimal(color.rgba.g) + // blue + const blue = keepDecimal(color.rgba.b) + // HSV饱和度 + const hsvSaturation = keepDecimal(color.hsva.s, 2) + // value + const value = keepDecimal(color.hsva.v, 2) + if (input == null) return color + function isShowAlpha(mode) { + return showAlpha ? mode + 'a' : mode + } + if (typeof input === 'string') { + if (mode === 'hex') { + return showAlpha ? color.hexa : color.hex + } else if (mode === 'hsl') { + return `${isShowAlpha(mode)}(${hue}, ${hslSaturation}, ${lightness}${ + showAlpha ? ', ' + color.alpha : '' + })` + } else if (mode === 'rgb') { + return `${isShowAlpha(mode)}(${red}, ${green}, ${blue}${showAlpha ? ', ' + color.alpha : ''})` + } else if (mode === 'hsv') { + return `${isShowAlpha(mode)}(${hue}, ${hsvSaturation}, ${value}${ + showAlpha ? ', ' + color.alpha : '' + })` + } + return input.length === 7 ? color.hex : color.hexa + } + + if (typeof input === 'object') { + const shouldStrip = typeof input.a === 'number' && input.a === 0 ? !!input.a : !input.a + if (has(input, ['r', 'g', 'b'])) return stripAlpha(color.rgba, shouldStrip) + else if (has(input, ['h', 's', 'l'])) return stripAlpha(color.hsla, shouldStrip) + else if (has(input, ['h', 's', 'v'])) return stripAlpha(color.hsva, shouldStrip) + } +} + +export function hasAlpha(color: Color): boolean { + if (!color) return false + + if (typeof color === 'string') { + return color.length > 7 + } + + if (typeof color === 'object') { + return has(color, ['a']) || has(color, ['alpha']) + } + + return false +} +export const elementResize = (parentElement: HTMLElement): position => { + const left = ref(0) + const top = ref(0) + window.addEventListener('resize', () => { + left.value = parentElement?.getBoundingClientRect().left + top.value = + parentElement?.getBoundingClientRect().top + parentElement?.getBoundingClientRect().height + }) + return { + left, + top + } +} + +export function RGBtoRGBA(rgba: RGBA): RGBA { + if (typeof rgba === 'string') { + rgba = (/rgba?\((.*?)\)/.exec(rgba) || ['', '0,0,0,1'])[1].split(',') + return { + r: Number(rgba[0]) || 0, + g: Number(rgba[1]) || 0, + b: Number(rgba[2]) || 0, + a: Number(rgba[3] ? rgba[3] : 1) // Avoid the case of 0 + } + } else { + return rgba + } +} +export function RGBtoHSV(rgb: RGB): HSV { + if (!rgb) return { h: 0, s: 1, v: 1 } + + const r = rgb.r / 255 + const g = rgb.g / 255 + const b = rgb.b / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + + let h = 0 + + if (max !== min) { + if (max === r) { + h = 60 * (0 + (g - b) / (max - min)) + } else if (max === g) { + h = 60 * (2 + (b - r) / (max - min)) + } else if (max === b) { + h = 60 * (4 + (r - g) / (max - min)) + } + } + + if (h < 0) h = h + 360 + + const s = max === 0 ? 0 : (max - min) / max + const hsv = [h, s, max] + + return { h: hsv[0], s: hsv[1].toFixed(2), v: hsv[2].toFixed(2) } +} +export function HSVtoHSL(hsv: HSV): HSL { + const { h, s, v } = hsv + + const l = Number((v - (v * s) / 2).toFixed(2)) + + const sprime = l === 1 || l === 0 ? 0 : (v - l) / Math.min(l, 1 - l) + + return { h, s: Number(sprime.toFixed(2)), l } +} diff --git a/packages/devui-vue/devui/color-picker/src/utils/color.ts b/packages/devui-vue/devui/color-picker/src/utils/color.ts new file mode 100644 index 0000000000..435f0913c4 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/utils/color.ts @@ -0,0 +1,47 @@ +export const color: string[] = [ + '#ffffff', + '#ffd7d5', + '#ffdaa9', + '#fffed5', + '#d4fa00', + '#73fcd6', + '#a5c8ff', + '#ffacd5', + '#ff7faa', + '#d6d6d6', + '#ffacaa', + '#ffb995', + '#fffb00', + '#73fa79', + '#00fcff', + '#78acfe', + '#d84fa9', + '#ff4f79', + '#b2b2b2', + '#d7aba9', + '#ff6827', + '#ffda51', + '#00d100', + '#00d5ff', + '#0080ff', + '#ac39ff', + '#ff2941', + '#888888', + '#7a4442', + '#ff4c00', + '#ffa900', + '#3da742', + '#3daad6', + '#0052ff', + '#7a4fd6', + '#d92142', + '#000000', + '#7b0c00', + '#ff4c41', + '#d6a841', + '#407600', + '#007aaa', + '#021eaa', + '#797baa', + '#ab1942' +] diff --git a/packages/devui-vue/devui/color-picker/src/utils/composeable.ts b/packages/devui-vue/devui/color-picker/src/utils/composeable.ts new file mode 100644 index 0000000000..507f2cf1ad --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/utils/composeable.ts @@ -0,0 +1,43 @@ +import { Ref, ref, watch } from 'vue' +import { ColorPickerColor, CssColorObject } from './color-utils-types' +export function colorPickerResize( + colorCubeRef: Ref, + top: Ref, + left: Ref +): void { + const rect = colorCubeRef.value?.getBoundingClientRect() + left.value = rect?.left + top.value = rect?.top + window.scrollY + rect.height +} +export function isExhibitionColorPicker( + event: PointerEvent, + colorCubeRef: Ref, + pickerRef: Ref, + showColorPicker: Ref +): void { + if (colorCubeRef.value?.contains(event.target)) { + showColorPicker.value = true + } + if (!!pickerRef.value && !pickerRef.value?.contains(event.target)) { + showColorPicker.value = !showColorPicker.value + } +} +export function useReactive(source: () => T): Ref { + const model = ref() + model.value = source() + watch(source, (newValue) => { + model.value = newValue + }) + return model +} + +// 根据 value 饱和度 判断文本颜色 +export function changeColorValue(value: ColorPickerColor, maxValue: number): CssColorObject { + if (value.alpha > maxValue) { + return value.hsva.v > maxValue && value.hsva.s < maxValue + ? { color: '#000' } + : { color: '#fff' } + } else { + return { color: '#000' } + } +} diff --git a/packages/devui-vue/devui/color-picker/src/utils/domDragger.ts b/packages/devui-vue/devui/color-picker/src/utils/domDragger.ts new file mode 100644 index 0000000000..5e520c8073 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/utils/domDragger.ts @@ -0,0 +1,365 @@ +export const isObject = (val: unknown): val is Record => + val !== null && typeof val === 'object' + +export const isString = (val: unknown): val is string => typeof val === 'string' + +export const enum NodeType { + ELEMENT_NODE = 1, + ATTRIBUTE_NODE = 2, + TEXT_NODE = 3, + CDATA_SECTION_NODE = 4, + ENTITY_REFERENCE_NODE = 5, + COMMENT_NODE = 6, + PROCESSING_INSTRUCTION_NODE = 7, + DOCUMENT_NODE = 9 +} + +export interface DragEventOptions { + drag?: (event: Event) => void + start?: (event: Event) => void + end?: (event: Event) => void +} + +export type ScrollElement = Element | Window + +export class DOMUtils { + static isWindow(val: unknown): val is Window { + return val === window + } + + /** + * 添加事件 + * + * + * @param element 如果为null将不会添加事件 + * @param event + * @param handler + * @param options + */ + static addEventListener( + element: HTMLElement | Document | Window | null, + event: string, + handler: EventListenerOrEventListenerObject, + options: boolean | AddEventListenerOptions = false + ): void { + if (element && event && handler) { + element.addEventListener(event, handler, options) + } + } + + /** + * 移除事件 + * + * @param element 如果为null将不会移除事件 + * @param event + * @param handler + * @param options + */ + static removeEventListener( + element: HTMLElement | Document | Window | null, + event: string, + handler: EventListenerOrEventListenerObject, + options: boolean | EventListenerOptions = false + ): void { + if (element && event && handler) { + element.removeEventListener(event, handler, options) + } + } + + /** + * 触发拖拽事件 + * + * @param element + * @param options + */ + static triggerDragEvent(element: HTMLElement, options: DragEventOptions): void { + let isDragging = false + + const moveFn = function (event: Event) { + options.drag?.(event) + } + + const upFn = (event: Event) => { + DOMUtils.removeEventListener(document, 'mousemove', moveFn) + DOMUtils.removeEventListener(document, 'mouseup', upFn) + DOMUtils.removeEventListener(document, 'touchmove', moveFn) + DOMUtils.removeEventListener(document, 'touchend', upFn) + document.onselectstart = null + document.ondragstart = null + + isDragging = false + options.end?.(event) + } + const downFn = (event: Event) => { + if (isDragging) return + document.onselectstart = () => false + document.ondragstart = () => false + DOMUtils.addEventListener(document, 'mousemove', moveFn) + DOMUtils.addEventListener(document, 'mouseup', upFn) + DOMUtils.addEventListener(document, 'touchmove', moveFn) + DOMUtils.addEventListener(document, 'touchend', upFn) + isDragging = true + + options.start?.(event) + } + DOMUtils.addEventListener(element, 'mousedown', downFn) + DOMUtils.addEventListener(element, 'touchstart', downFn) + return + } + + static getBoundingClientRect(element: HTMLElement): DOMRect | null { + if (element && isObject(element) && element.nodeType === NodeType.ELEMENT_NODE) { + return element.getBoundingClientRect() + } + + return null + } + + /** + * 判断是否存在className样式 + * + * @param element + * @param className + */ + public static hasClass(element: HTMLElement, className: string): boolean { + if ( + element && + isObject(element) && + isString(className) && + element.nodeType === NodeType.ELEMENT_NODE + ) { + return element.classList.contains(className.trim()) + } + return false + } + + /** + * 添加样式 + * + * @param element + * @param className + */ + public static addClass(element: HTMLElement, className: string): void { + if ( + element && + isObject(element) && + isString(className) && + element.nodeType === NodeType.ELEMENT_NODE + ) { + className = className.trim() + if (!DOMUtils.hasClass(element, className)) { + const cl = element.className + element.className = cl ? cl + ' ' + className : className + } + } + } + + /** + * 移除样式 + * + * @param element + * @param className + */ + public static removeClass(element: HTMLElement, className: string): void { + if ( + element && + isObject(element) && + isString(className) && + element.nodeType === NodeType.ELEMENT_NODE && + typeof element.className === 'string' + ) { + className = className.trim() + const classes = element.className.trim().split(' ') + for (let i = classes.length - 1; i >= 0; i--) { + classes[i] = classes[i].trim() + if (!classes[i] || classes[i] === className) { + classes.splice(i, 1) + } + } + element.className = classes.join(' ') + } + } + + /** + * 切换样式 + * + * @param element + * @param className + * @param force + */ + public static toggleClass(element: HTMLElement, className: string, force?: boolean): void { + if ( + element && + isObject(element) && + isString(className) && + element.nodeType === NodeType.ELEMENT_NODE + ) { + element.classList.toggle(className, force) + } + } + + /** + * 替换样式 + * + * @param element + * @param oldClassName + * @param newClassName + */ + public static replaceClass( + element: HTMLElement, + oldClassName: string, + newClassName: string + ): void { + if ( + element && + isObject(element) && + isString(oldClassName) && + isString(newClassName) && + element.nodeType === NodeType.ELEMENT_NODE + ) { + oldClassName = oldClassName.trim() + newClassName = newClassName.trim() + DOMUtils.removeClass(element, oldClassName) + DOMUtils.addClass(element, newClassName) + } + } + + static getScrollTop(el: ScrollElement): number { + const top = 'scrollTop' in el ? el.scrollTop : el.pageYOffset + + // iOS scroll bounce cause minus scrollTop + return Math.max(top, 0) + } + + static setScrollTop(el: ScrollElement, value: number): void { + if ('scrollTop' in el) { + el.scrollTop = value + } else { + el.scrollTo(el.scrollX, value) + } + } + + static getRootScrollTop(): number { + return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0 + } + + static setRootScrollTop(value: number): void { + DOMUtils.setScrollTop(window, value) + DOMUtils.setScrollTop(document.body, value) + } + + static getElementTop(el: ScrollElement, scroller?: HTMLElement): number { + if (DOMUtils.isWindow(el)) { + return 0 + } + + const scrollTop = scroller ? DOMUtils.getScrollTop(scroller) : DOMUtils.getRootScrollTop() + return el.getBoundingClientRect().top + scrollTop + } + + static getVisibleHeight(el: ScrollElement): number { + if (DOMUtils.isWindow(el)) { + return el.innerHeight + } + return el.getBoundingClientRect().height + } + + static isHidden(el: HTMLElement): boolean { + if (!el) { + return false + } + + const style = window.getComputedStyle(el) + const hidden = style.display === 'none' + + // 在以下情况下,offsetParent返回null: + // 1. 元素或其父元素的display属性设置为none. + // 2. 元素的position属性设置为fixed + const parentHidden = el.offsetParent === null && style.position !== 'fixed' + + return hidden || parentHidden + } + + /** + * 触发事件 + * + * @param el + * @param type + */ + static triggerEvent(el: Element, type: string): void { + if ('createEvent' in document) { + // modern browsers, IE9+ + const e = document.createEvent('HTMLEvents') + e.initEvent(type, false, true) + el.dispatchEvent(e) + } + } + + /** + * 计算相对于中心点的旋转角度 + * @param element + * @param event + */ + static calcAngle(element: HTMLElement, event: MouseEvent): number { + const rect = element.getBoundingClientRect() + + const originX = rect.left + rect.width / 2 + const originY = rect.top + rect.height / 2 + + //获得中心点和鼠标坐标连线,与y轴正半轴之间的夹角 + const x = Math.abs(originX - event.clientX) + const y = Math.abs(originY - event.clientY) + const z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)) + const cos = y / z + const rad = Math.acos(cos) //用反三角函数求弧度 + let angle = Math.floor(180 / (Math.PI / rad)) //将弧度转换成角度 + + if (event.clientX > originX && event.clientY > originY) { + //鼠标在第四象限 + angle = 180 - angle + } + + if (event.clientX == originX && event.clientY > originY) { + //鼠标在y轴负方向上 + angle = 180 + } + + if (event.clientX > originX && event.clientY == originY) { + //鼠标在x轴正方向上 + angle = 90 + } + + if (event.clientX < originX && event.clientY > originY) { + //鼠标在第三象限 + angle = 180 + angle + } + + if (event.clientX < originX && event.clientY == originY) { + //鼠标在x轴负方向 + angle = 270 + } + + if (event.clientX < originX && event.clientY < originY) { + //鼠标在第二象限 + angle = 360 - angle + } + + return angle + } + + /** + * querySelector + * + * @param selectors + * @param parentElement + */ + static querySelector( + selectors: string, + parentElement?: HTMLElement + ): E | null { + if (parentElement) { + return parentElement.querySelector(selectors) + } + return document.querySelector(selectors) + } +} diff --git a/packages/devui-vue/devui/color-picker/src/utils/helpers.ts b/packages/devui-vue/devui/color-picker/src/utils/helpers.ts new file mode 100644 index 0000000000..9052074fb2 --- /dev/null +++ b/packages/devui-vue/devui/color-picker/src/utils/helpers.ts @@ -0,0 +1,48 @@ +export function padEnd(str: string, length: number, char = '0'): string { + return str + char.repeat(Math.max(0, length - str.length)) +} +export function chunk(str: string, size = 1): string[] { + const chunked: string[] = [] + let index = 0 + while (index < str.length) { + chunked.push(str.substr(index, size)) + index += size + } + return chunked +} +export function mergeObjects(source, target): any { + return Object.assign(source, target) +} +export function parseHex(value: string): number { + return parseInt(value, 16) +} +export const clamp = (value: number, min: number, max: number): number => { + return min < max + ? value < min + ? min + : value > max + ? max + : value + : value < max + ? max + : value > min + ? min + : value +} +export const upperCase = (word: string): string => { + return word.toLocaleUpperCase() +} +export const lowerCase = (word: string): string => { + return word.toLocaleLowerCase() +} +export function splitStr(str: string, chars: string): string { + return str.split(chars)[0] +} +// 保留小数工具 +export function keepDecimal(value: number, digits = 0): number { + const COUNT_VALUE = 10 ** digits + return Math.round(value * COUNT_VALUE) / COUNT_VALUE +} +export function has(obj: any, key: string[]): boolean { + return key.every((k) => obj.hasOwnProperty(k)) +} diff --git a/packages/devui-vue/devui/comment/index.ts b/packages/devui-vue/devui/comment/index.ts index d9fb1203f0..c55970c9e5 100644 --- a/packages/devui-vue/devui/comment/index.ts +++ b/packages/devui-vue/devui/comment/index.ts @@ -10,7 +10,7 @@ export { Comment } export default { title: 'Comment 评论', category: '数据展示', - status: '70%', // TODO: 组件若开发完成则填入"已完成",并删除该注释 + status: '100%', install(app: App): void { app.use(Comment as any) } diff --git a/packages/devui-vue/devui/comment/src/comment.scss b/packages/devui-vue/devui/comment/src/comment.scss index fb2752bd93..a8e5740774 100644 --- a/packages/devui-vue/devui/comment/src/comment.scss +++ b/packages/devui-vue/devui/comment/src/comment.scss @@ -1,30 +1,39 @@ @import '../../styles-var/devui-var.scss'; + .devui-comment { display: flex; align-items: flex-start; - &-avatar{ + + &-avatar { margin: 0 16px 0 0; } + &-right{ - + } - &-head{ + + &-head { display: flex; align-items: center; } - &-author{ + + &-author { padding: 0 8px 0 0; font-size: $devui-font-size; color: $devui-text-weak; } - &-datetime{ + + &-datetime { font-size: $devui-font-size; color: $devui-aide-text; } - &-content{ + + &-content { font-size: $devui-text; } - &-actions{ + + &-actions { + padding: 0; list-style-type: none; margin: 12px 0 0; } diff --git a/packages/devui-vue/devui/comment/src/comment.tsx b/packages/devui-vue/devui/comment/src/comment.tsx index aeec79fefd..438063c82d 100644 --- a/packages/devui-vue/devui/comment/src/comment.tsx +++ b/packages/devui-vue/devui/comment/src/comment.tsx @@ -1,7 +1,20 @@ import { defineComponent } from 'vue' import { commentProps, CommentProps } from './comment-types' import './comment.scss' - +/* + * date:2021-12-18 + * author:njl + * + * actions 底部操作栏 + * author 作者区域 + * avatar 头像区域 + * content 内容操作区域 + * datetime 时间区域 + * avatarDom 头像可只传入地址 + * actionDom 操作区域根据传入的组件数量来生成相应的li标签 + * + * 目前可成为参数的为 avatar,actions 其他均为具名插槽的形式,后期可继续根据需要改造 +**/ export default defineComponent({ name: 'DComment', props: commentProps, @@ -10,14 +23,15 @@ export default defineComponent({ setup(props, { slots }) { return () => { const getAction = (actions:any) => { + if (!actions || !actions.length) { return null; } - const actionList = actions.map((action:any, index:number) =>
  • {action}
  • ); + const actionList = actions.map((action:any, index:number) =>
  • {action}
  • ); return actionList; }; - const actions = props.actions ?? slots.actions?.(); + const actions = props.actions ?? slots.actions?.(); const author = props.author ?? slots.author?.(); const avatar = props.avatar ?? slots.avatar?.(); const content = props.content ?? slots.content?.(); diff --git a/packages/devui-vue/devui/dragdrop/index.ts b/packages/devui-vue/devui/dragdrop/index.ts index b203021a07..d1953c4993 100644 --- a/packages/devui-vue/devui/dragdrop/index.ts +++ b/packages/devui-vue/devui/dragdrop/index.ts @@ -1,8 +1,9 @@ import type { App } from 'vue' import DraggableDirective from './src/draggable-directive' import DroppableDirective from './src/droppable-directive' +import SortableDirective from './src/sortable-directive' -export { DraggableDirective, DroppableDirective } +export { DraggableDirective, DroppableDirective, SortableDirective } export default { title: 'Dragdrop 拖拽', @@ -11,5 +12,6 @@ export default { install(app: App): void { app.directive('DDraggable', DraggableDirective) app.directive('DDroppable', DroppableDirective) + app.directive('DSortable', SortableDirective) } } diff --git a/packages/devui-vue/devui/dragdrop/src/constant.ts b/packages/devui-vue/devui/dragdrop/src/constant.ts new file mode 100644 index 0000000000..98c9224ec4 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop/src/constant.ts @@ -0,0 +1 @@ +export const shadowId = 'shadow0611' \ No newline at end of file diff --git a/packages/devui-vue/devui/dragdrop/src/draggable-directive.ts b/packages/devui-vue/devui/dragdrop/src/draggable-directive.ts index c53e3342c1..fa333e2bdf 100644 --- a/packages/devui-vue/devui/dragdrop/src/draggable-directive.ts +++ b/packages/devui-vue/devui/dragdrop/src/draggable-directive.ts @@ -1,11 +1,41 @@ +import { changeDragState, deleteInsertedSortableShadow } from './utils' +import { shadowId } from './constant' + export default { - mounted(el: HTMLElement): void { - el.setAttribute('draggable', 'true') + /** + * + * @param el + * @description + * 1、绑定该指令的element将会具备拖拽能力 + * 2、为各元素进行初始化配置 + * 2.1、dragFlag: 是否处于拖拽中 + * 2.2、dragOverFlag: 是否处于可放置区域 + * + * 1、整体思路 + * 1.1、为每个绑定drag指令的元素维护状态 + * 1.1.1、状态集合:dragStart、drag、dragover、drop、shouldCreateShadow + * + * 1.2、进入drop区域后,确保drop区域能够获取正在进行drag的元素 + */ + mounted(el: HTMLElement, binding: unknown): void { + el.setAttribute('draggable', 'true'); el.style.cursor = 'grab' // dragstart/drag/dragend - el.addEventListener('dragstart', (event: DragEvent) => { - event.dataTransfer.setData('originId', el.id) - }) + el.addEventListener('drag', () => { + changeDragState(el, el.id, 'true', 'true', 'false', 'false', 'false', 'true') + if (binding.instance.$root.dropElement && document.getElementById(shadowId)){ + deleteInsertedSortableShadow(binding.instance.$root.dropElement) // 如何让它仅执行1次? + binding.instance.$root.dropElement = null + } + }, false) + + // dragStart事件为每个绑定元素进行初始化 + el.addEventListener('dragstart', ()=>{ + // el or binding.instance or vnode.context + changeDragState(el, el.id, 'true', 'true', 'false', 'false', 'false', 'false') + binding.instance.$root.identity = el.id + el.dataset.dragArea = el.parentNode.className + }, false) }, } diff --git a/packages/devui-vue/devui/dragdrop/src/droppable-directive.ts b/packages/devui-vue/devui/dragdrop/src/droppable-directive.ts index d60618868e..289ad8ef7e 100644 --- a/packages/devui-vue/devui/dragdrop/src/droppable-directive.ts +++ b/packages/devui-vue/devui/dragdrop/src/droppable-directive.ts @@ -1,15 +1,49 @@ +import { changeDragState } from './utils' + export default { - mounted(el: HTMLElement): void { - // dragenter/dragover/dragend/drop + /** + * + * @param el + * @description + * dragOver: + * 1、生成与清除阴影的时机 + * 1.1、生成时机(只生成一次): dragFlag === true && dragOverFlag === true + * drop: + * 1、完成放的操作 + * 1.1、清除相应的阴影 + */ + mounted(el: HTMLElement, binding:unknown): void { + // dragenter/dragover/dragend/drop el.addEventListener('dragover', (event: DragEvent) => { event.preventDefault() - }) + const dragId = binding.instance.$root.identity + changeDragState(document.getElementById(dragId), dragId, 'true', 'false', 'true', 'false', 'false', 'false') + document.getElementById(dragId).dataset.dropArea = [...el.childNodes][1].className + }, false) + // 新增两个标识解决战斗,即dragStart区域、drop区域、sortableDrop区域 el.addEventListener('drop', (event: DragEvent) => { - const originId = event.dataTransfer.getData('originId') - const originNodeCopy = document.getElementById(originId).cloneNode(true) - const targetNode: any = event.target - targetNode.append(originNodeCopy) + event.preventDefault() + const dragId = binding.instance.$root.identity + if (document.getElementById(dragId).dataset.dropArea == document.getElementById(dragId).dataset.dragArea){ + return + } + // 如何定义可放置区域这个问题得商榷一下 + const childrenArr = [...Array.from(el.children)[1].children] + if (childrenArr.length > 0){ + for (let index = 0; index < childrenArr.length; index++){ + const childrenYRange = childrenArr[index].getBoundingClientRect().top + childrenArr[index].offsetHeight / 2 + if (parseFloat(event.clientY) < parseFloat(childrenYRange)){ + el.children[1].insertBefore(document.getElementById(dragId), childrenArr[index]) + break + } + if (index === childrenArr.length-1){ + el.children[1].appendChild(document.getElementById(dragId)) + } + } + }else { + el.childNodes[1].appendChild(document.getElementById(dragId)) + } }) }, } diff --git a/packages/devui-vue/devui/dragdrop/src/sortable-directive.ts b/packages/devui-vue/devui/dragdrop/src/sortable-directive.ts new file mode 100644 index 0000000000..2066b831d4 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop/src/sortable-directive.ts @@ -0,0 +1,48 @@ +import { shadowId } from './constant' +import { changeDragState, createInsertSortableShadow, insertDragElement } from './utils' + + +export default { + /** + * + * @param el + * @description + * 此命令用于将元素变为可放置的元素并且支持排序 + * dragover: + * 1、说明此时进入可排序放置的区域 + * 2、此时应该生成相应的可排序的shadow + * drop: + * 1、可放置区域里如果没有拖拽元素,直接放置 + * 2、可放置区域里如果有其他的可拖拽元素,需要对比放置到正确的位置上 + */ + mounted(el: HTMLElement, binding:unknown):void { + el.addEventListener('dragover', function (event: DragEvent){ + event.preventDefault() + const targetNode: any = event.target; + const dragId = binding.instance.$root.identity + if (!binding.instance.$root.dropElement){ + binding.instance.$root.dropElement = [...el.childNodes][1] + } + changeDragState(document.getElementById(binding.instance.$root.identity), binding.instance.$root.identity, 'true', 'false', 'true', 'false', 'true', 'false') + const { dragover, shouldCreateShadow } = document.getElementById(dragId).dataset + if (dragover == 'true'){ + if (shouldCreateShadow == 'true'){ + createInsertSortableShadow([...targetNode.children][1], event, dragId) + } + } + + }) + el.addEventListener('drop', function (event: DragEvent){ + // 获取可放置区域 + const dropArea = [...el.childNodes][1] + const dragId = binding.instance.$root.identity + dropArea.removeChild(document.getElementById(shadowId)) + if ([...dropArea.childNodes].length == 0){ + dropArea.appendChild(document.getElementById(dragId)) + }else { + insertDragElement(dropArea, dragId, event) + } + changeDragState(document.getElementById(dragId), dragId, 'false', 'false', 'false', 'true', 'false', 'false') + }) + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/dragdrop/src/utils.ts b/packages/devui-vue/devui/dragdrop/src/utils.ts new file mode 100644 index 0000000000..0b07447eba --- /dev/null +++ b/packages/devui-vue/devui/dragdrop/src/utils.ts @@ -0,0 +1,143 @@ +import { shadowId } from './constant' + +/** + * + * @param id + * @descriprion + * 根据id获取非内联样式元素的样式 + */ +function getElementStyle (id: string, styleName: string):string { + return document.getElementById(id).currentStyle ? document.getElementById(id).currentStyle[styleName] : window.getComputedStyle( + document.getElementById(id), + styleName + ) +} + +/** + * + * @param originId + * @description + * 根据拖拽的id生成相应的阴影 + * 如何生成shadow? + * 情况一: dragable -> drop without sortable + * 情况二: anything -> drop without anything + */ +function createShadow (originId:string):HTMLElement { + const shadow = document.createElement('div'); + shadow.id = shadowId + shadow.style.background = 'rgb(206, 215, 255)' + shadow.style.width = getElementStyle(originId, 'width') + shadow.style.height = '20px' + return shadow +} + +/** + * + * @param el + * @param originId + * @param dragStart + * @param drag + * @param dragover + * @param drop + * @param shouldCreateShadow + * @param dragFlag + * @description + * 改变拖拽元素相应的状态 + */ +function changeDragState (el:string, originId:string, dragStart:string, drag:string, dragover:string, drop:string, shouldCreateShadow:string, dragFlag: string): void{ + el.dataset.originId = originId + el.dataset.dragStart = dragStart + el.dataset.dragover = dragover + el.dataset.drop = drop + el.dataset.shouldCreateShadow = shouldCreateShadow + el.dataset.dragFlag = dragFlag +} + +/** + * + * @param compareElement + * @returns + * @description + * 计算可对比元素的高度 + */ +function computeCompareElementHeight (compareElement: HTMLCollection): unknown{ + return compareElement.getBoundingClientRect().top + Math.floor(compareElement.offsetHeight / 2) +} + +/** + * + * @param sortDropArea + * @param mouseObject + * 1、首先确认可放置区域 + * 2、确保每个元素只生成一次shadow + * 3、 + */ +function createInsertSortableShadow (sortDropArea: unknown, mouseObject: unknown, originId: string):void { + const sortDropAreaArr: Array = [...sortDropArea.children] + if (sortDropAreaArr.length == 0){ + if (!document.getElementById(shadowId)){ + const shadowElement = createShadow(originId) + sortDropArea.appendChild(shadowElement) + } + }else { + for (let index = 0; index < sortDropAreaArr.length; index++){ + const compareHeight = computeCompareElementHeight(sortDropAreaArr[index]) + document.getElementById(shadowId) ? sortDropArea.removeChild(document.getElementById(shadowId)) : null + if (index == sortDropAreaArr.length-1){ + sortDropArea.appendChild(createShadow(originId)) + break + } + if (Math.floor(mouseObject.clientY)<= compareHeight){ + sortDropArea.insertBefore(createShadow(originId), sortDropAreaArr[index]) + break + } + } + } +} + +/** + * + * @param dropAreaContainer + * @param dragId + * @param mouseObject + * @description + * 向sortable区域插入拖拽元素 + */ +function insertDragElement (dropAreaContainer: HTMLCollection, dragId: string, mouseObject: MouseEvent): void { + for (let index = 0; index < [...dropAreaContainer.children].length; index++){ + if (index == [...dropAreaContainer.children].length-1){ + dropAreaContainer.appendChild(document.getElementById(dragId)) + break + } + if (Math.floor(mouseObject.clientY) <= computeCompareElementHeight([...dropAreaContainer.children][index])){ + dropAreaContainer.insertBefore(document.getElementById(dragId), [...dropAreaContainer.children][index]) + break + } + } +} + +/** + * + * @param dropSortArea + * @description + * 删除可排序区域中的shadow + */ +function deleteInsertedSortableShadow (dropSortArea: unknown):void{ + if (dropSortArea){ + if (document.getElementById(shadowId)){ + if (dropSortArea.contains(document.getElementById(shadowId))){ + dropSortArea.removeChild(document.getElementById(shadowId)) + } + } + } +} + + +export { + createShadow, + changeDragState, + createInsertSortableShadow, + deleteInsertedSortableShadow, + computeCompareElementHeight, + insertDragElement +} \ No newline at end of file diff --git a/packages/devui-vue/devui/drawer/index.ts b/packages/devui-vue/devui/drawer/index.ts index 2ad72eb001..f0b0a21e7d 100644 --- a/packages/devui-vue/devui/drawer/index.ts +++ b/packages/devui-vue/devui/drawer/index.ts @@ -1,18 +1,21 @@ -import type { App } from 'vue' -import Drawer from './src/drawer' +import type { App } from 'vue'; +import Drawer from './src/drawer'; +import DrawerService from './src/drawer-service'; -Drawer.install = function(app: App): void { - app.component(Drawer.name, Drawer) -} - -export { Drawer } +export { Drawer, DrawerService }; +// TODO: no-service model exists memory leak +// rest tasks +// 1. draggable width +// 2. function of the 1st icon in header-component +// 3. rest service-model api +// 4. typescript type of props export default { title: 'Drawer 抽屉板', category: '反馈', - status: '50%', + status: '75%', install(app: App): void { - - app.use(Drawer as any) - } -} + app.component(Drawer.name, Drawer); + app.config.globalProperties.$drawerService = new DrawerService(); + }, +}; diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-body.scss b/packages/devui-vue/devui/drawer/src/components/drawer-body.scss deleted file mode 100644 index 707fae3801..0000000000 --- a/packages/devui-vue/devui/drawer/src/components/drawer-body.scss +++ /dev/null @@ -1,43 +0,0 @@ -@import '../../../style/devui.scss'; - -.devui-drawer { - position: fixed; - top: 0; - left: 0; - height: 100vh; -} - -.devui-overlay-wrapper { - display: flex; - justify-content: center; - align-items: center; - position: absolute; - top: 0; - bottom: 0; - width: 100vw; -} - -.devui-overlay-backdrop { - position: absolute; - top: 0; - left: 0; - background: $devui-shadow; - width: 100vw; - height: 100vh; -} - -.devui-drawer-nav { - position: absolute; - top: 0; - bottom: 0; - border-radius: $devui-border-radius; - background: $devui-base-bg; -} - -.devui-drawer-content { - border-radius: $devui-border-radius; - overflow: auto; - box-shadow: $devui-shadow-length-fullscreen-overlay $devui-shadow; - padding: 20px; - height: 100vh; -} \ No newline at end of file diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-body.tsx b/packages/devui-vue/devui/drawer/src/components/drawer-body.tsx deleted file mode 100644 index a52965f7c0..0000000000 --- a/packages/devui-vue/devui/drawer/src/components/drawer-body.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { defineComponent, inject, computed } from 'vue' - -import './drawer-body.scss' - -export default defineComponent({ - name: 'DrawerBody', - setup(props, { slots }) { - const isFullScreen: any = inject('isFullScreen') - const closeDrawer: any = inject('closeDrawer') - const zindex: number = inject('zindex') - const isCover: boolean = inject('isCover') - const position: any = inject('position') - const width: any = inject('width') - const visible: boolean = inject('visible') - const backdropCloseable: any = inject('backdropCloseable') - - const navRight = computed(() => position.value === 'right' ? { 'right': 0 } : { 'left': 0 }) - const navWidth = computed(() => isFullScreen.value ? '100vw' : width.value) - - const clickContent = (e) => { - e.stopPropagation() - } - - const handleDrawerClose = () => { - if (!backdropCloseable.value) return; - closeDrawer(); - } - - return { - zindex, - slots, - isCover, - navRight, - navWidth, - visible, - clickContent, - handleDrawerClose, - } - }, - - render() { - const { - zindex, slots, isCover, navRight, navWidth, visible, handleDrawerClose - } = this - - if (!visible) return null - - return ( -
    - {isCover ?
    : null} -
    -
    -
    - {slots.default ? slots.default() : null} -
    -
    -
    -
    - ) - } -}) \ No newline at end of file diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-container.tsx b/packages/devui-vue/devui/drawer/src/components/drawer-container.tsx deleted file mode 100644 index c45f3b2545..0000000000 --- a/packages/devui-vue/devui/drawer/src/components/drawer-container.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { defineComponent, inject } from 'vue' - -export default defineComponent({ - name: 'DrawerContainer', - setup() { - const visible = inject('visible') - return { visible } - }, - render() { - const { visible } = this - if (!visible) return null - return
    内容区域
    - } -}) \ No newline at end of file diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-header.scss b/packages/devui-vue/devui/drawer/src/components/drawer-header.scss deleted file mode 100644 index ee1776a5d0..0000000000 --- a/packages/devui-vue/devui/drawer/src/components/drawer-header.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import '../drawer.scss'; - -.devui-drawer-header { - display: flex; - flex-direction: row; - justify-content: flex-end; - - & .devui-drawer-header-item + .devui-drawer-header-item { - padding-left: 12px; - } -} diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-header.tsx b/packages/devui-vue/devui/drawer/src/components/drawer-header.tsx deleted file mode 100644 index 7e3e3a05c0..0000000000 --- a/packages/devui-vue/devui/drawer/src/components/drawer-header.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { defineComponent, ref, inject, computed } from 'vue' - -import './drawer-header.scss' - -export default defineComponent({ - name: 'DrawerHeader', // 头部 - emits: ['toggleFullScreen', 'close'], - setup(props, ctx) { - const isFullScreen = ref(false) - - const visible: boolean = inject('visible') - - const fullScreenClassName = computed(() => isFullScreen.value ? 'icon icon-minimize' : 'icon icon-maxmize') - - const handleFullScreen = (e) => { - e.stopPropagation() - isFullScreen.value = !isFullScreen.value - ctx.emit('toggleFullScreen') - } - - const handleDrawerClose = () => { - ctx.emit('close') - } - - return { fullScreenClassName, visible, handleFullScreen, handleDrawerClose, } - }, - render() { - const { handleFullScreen, handleDrawerClose, visible, fullScreenClassName } = this - - if (!visible) return null - - return ( -
    -
    - -
    -
    - -
    -
    - -
    -
    - ) - } -}) diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-overlay.scss b/packages/devui-vue/devui/drawer/src/components/drawer-overlay.scss new file mode 100644 index 0000000000..1cec622700 --- /dev/null +++ b/packages/devui-vue/devui/drawer/src/components/drawer-overlay.scss @@ -0,0 +1,20 @@ +@import '../../../styles-var/devui-var.scss'; + +.devui-drawer-overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: $devui-shadow; +} + +.drawer-overlay-fade-enter-active, +.drawer-overlay-fade-leave-active { + transition: opacity 0.1s linear; +} + +.drawer-overlay-fade-enter-from, +.drawer-overlay-fade-leave-to { + opacity: 0; +} diff --git a/packages/devui-vue/devui/drawer/src/components/drawer-overlay.tsx b/packages/devui-vue/devui/drawer/src/components/drawer-overlay.tsx new file mode 100644 index 0000000000..973e4b3a0d --- /dev/null +++ b/packages/devui-vue/devui/drawer/src/components/drawer-overlay.tsx @@ -0,0 +1,18 @@ +import { defineComponent, Transition } from 'vue'; +import type { SetupContext } from 'vue'; +import { drawerOverlayProps, DrawerOverlayProps } from '../drawer-types'; +import './drawer-overlay.scss'; + +export default defineComponent({ + name: 'DDrawerOverlay', + props: drawerOverlayProps, + emits: ['click'], + setup(props: DrawerOverlayProps, ctx: SetupContext) { + const handleClick = (e: Event) => { + ctx.emit('click', e); + }; + return () => ( + {props.visible &&
    }
    + ); + }, +}); diff --git a/packages/devui-vue/devui/drawer/src/drawer-service.tsx b/packages/devui-vue/devui/drawer/src/drawer-service.tsx new file mode 100644 index 0000000000..22dbae350d --- /dev/null +++ b/packages/devui-vue/devui/drawer/src/drawer-service.tsx @@ -0,0 +1,58 @@ +import { createApp, nextTick, onUnmounted, reactive } from 'vue'; +import type { App } from 'vue'; +import Drawer from './drawer'; +import { DrawerOptions } from './drawer-types'; + +const defaultOptions: DrawerOptions = { + modelValue: false, + content: '', + zIndex: 1000, + showOverlay: true, + escKeyCloseable: true, + position: 'right', + lockScroll: true, + closeOnClickOverlay: true, +}; + +function initInstance(state: DrawerOptions): App { + const container = document.createElement('div'); + const content = state.content; + delete state.content; + + const app: App = createApp({ + setup() { + const handleVisibleChange = () => { + state.modelValue = false; + }; + onUnmounted(() => { + console.log(111); + document.body.removeChild(container); + }); + return () => ( + + {content} + + ); + }, + }); + + document.body.appendChild(container); + app.mount(container); + return app; +} + +export default class DrawerService { + open(options: DrawerOptions): { close: () => void } { + const state: DrawerOptions = reactive({ ...defaultOptions, ...options }); + const app = initInstance(state); + + state.modelValue = true; + + return { + close: () => { + state.modelValue = false; + app.unmount(); + }, + }; + } +} diff --git a/packages/devui-vue/devui/drawer/src/drawer-types.ts b/packages/devui-vue/devui/drawer/src/drawer-types.ts index 207cf89ef5..498f09c187 100644 --- a/packages/devui-vue/devui/drawer/src/drawer-types.ts +++ b/packages/devui-vue/devui/drawer/src/drawer-types.ts @@ -1,11 +1,7 @@ -import type { ExtractPropTypes, PropType } from 'vue' +import type { ExtractPropTypes, PropType, Slot, Ref } from 'vue'; export const drawerProps = { - width: { - type: String, - default: '300px', - }, - visible: { + modelValue: { type: Boolean, default: false, }, @@ -13,7 +9,7 @@ export const drawerProps = { type: Number, default: 1000, }, - isCover: { + showOverlay: { type: Boolean, default: true, }, @@ -23,15 +19,40 @@ export const drawerProps = { }, position: { type: String as PropType<'left' | 'right'>, - default: 'left', + default: 'right', + }, + lockScroll: { + type: Boolean, + default: true, }, - backdropCloseable: { + closeOnClickOverlay: { type: Boolean, default: true, }, - beforeHidden: { - type: [Promise, Function] as PropType | (() => boolean | Promise)>, + beforeClose: { + type: Function as PropType<(done: () => void) => void>, }, -} as const +}; + +export const drawerOverlayProps = { + visible: { + type: Boolean, + default: false, + }, +}; + +type DrawerEmitEvent = 'update:modelValue' | 'close' | 'open'; + +export type DrawerEmit = (event: DrawerEmitEvent, result?: unknown) => void; + +export type DrawerProps = ExtractPropTypes; + +export type DrawerOverlayProps = ExtractPropTypes; + +export type DrawerOptions = Partial & { content?: string | Slot }; -export type DrawerProps = ExtractPropTypes +export type UseDrawerFn = { + drawerRef: Ref; + drawerClasses: Ref>; + handleOverlayClick: () => void; +}; diff --git a/packages/devui-vue/devui/drawer/src/drawer.scss b/packages/devui-vue/devui/drawer/src/drawer.scss index 8b13789179..3afef767e4 100644 --- a/packages/devui-vue/devui/drawer/src/drawer.scss +++ b/packages/devui-vue/devui/drawer/src/drawer.scss @@ -1 +1,52 @@ +@import '../../styles-var/devui-var.scss'; +.devui-drawer { + position: fixed; + top: 0; + bottom: 0; + width: 300px; + border-radius: $devui-border-radius; + background-color: $devui-base-bg; + transform: translateX(0); + opacity: 1; + overflow: auto; + box-shadow: $devui-shadow-length-fullscreen-overlay $devui-shadow; + + &-left { + left: 0; + } + + &-right { + right: 0; + } +} + +.drawer-fly { + &-right-enter-active { + transition: all 0.3s cubic-bezier(0.16, 0.75, 0.5, 1); + } + + &-right-leave-active { + transition: all 0.3s cubic-bezier(0.5, 0, 0.84, 0.25); + } + + &-right-enter-from, + &-right-leave-to { + opacity: 0; + transform: translateX(100%); + } + + &-left-enter-active { + transition: all 0.3s cubic-bezier(0.16, 0.75, 0.5, 1); + } + + &-left-leave-active { + transition: all 0.3s cubic-bezier(0.5, 0, 0.84, 0.25); + } + + &-left-enter-from, + &-left-leave-to { + opacity: 0; + transform: translateX(-100%); + } +} diff --git a/packages/devui-vue/devui/drawer/src/drawer.tsx b/packages/devui-vue/devui/drawer/src/drawer.tsx index 09c84e4d9a..37e7c09b6f 100644 --- a/packages/devui-vue/devui/drawer/src/drawer.tsx +++ b/packages/devui-vue/devui/drawer/src/drawer.tsx @@ -1,91 +1,27 @@ -import { defineComponent, ref, toRefs, watch, onUnmounted, Teleport, provide } from 'vue' -import { drawerProps, DrawerProps } from './drawer-types' - -import DrawerHeader from './components/drawer-header' -import DrawerContainer from './components/drawer-container' -import DrawerBody from './components/drawer-body' +import { defineComponent, Teleport, Transition } from 'vue'; +import { drawerProps, DrawerProps } from './drawer-types'; +import DrawerOverlay from './components/drawer-overlay'; +import { useDrawer } from './use-drawer'; +import './drawer.scss'; export default defineComponent({ name: 'DDrawer', + inheritAttrs: false, props: drawerProps, - emits: ['close', 'update:visible', 'afterOpened'], - setup(props: DrawerProps, { emit, slots }) { - const { - width, visible, zIndex, isCover, escKeyCloseable, position, - backdropCloseable, - } = toRefs(props) - const isFullScreen = ref(false) - - const fullScreenEvent = () => { - isFullScreen.value = !isFullScreen.value - } - - const closeDrawer = async () => { - const beforeHidden = props.beforeHidden; - let result = (typeof beforeHidden === 'function' ? beforeHidden(): beforeHidden) ?? false; - if (result instanceof Promise) { - result = await result; - } - if (result) return; - - emit('update:visible', false) - emit('close') - } - - const escCloseDrawer = (e) => { - if (e.code === 'Escape') { - closeDrawer() - } - } - - watch(visible, (val) => { - if (val) { - emit('afterOpened') - isFullScreen.value = false - } - if (escKeyCloseable && val) { - document.addEventListener('keyup', escCloseDrawer) - } else { - document.removeEventListener('keyup', escCloseDrawer) - } - }) - - provide('closeDrawer', closeDrawer) - provide('zindex', zIndex) - provide('isCover', isCover) - provide('position', position) - provide('width', width) - provide('visible', visible) - provide('isFullScreen', isFullScreen) - provide('backdropCloseable', backdropCloseable) - - onUnmounted(() => { - document.removeEventListener('keyup', escCloseDrawer) - }) - - return { - isFullScreen, - visible, - slots, - fullScreenEvent, - closeDrawer, - } - }, - render() { - const fullScreenEvent: any = this.fullScreenEvent - const closeDrawer: any = this.closeDrawer - - if (!this.visible) return null - - return ( - - - {this.slots.header ? this.slots.header() : - - } - {this.slots.default ? this.slots.default() : } - + emits: ['close', 'update:modelValue', 'open'], + setup(props: DrawerProps, { emit, slots, attrs }) { + const { drawerRef, drawerClasses, handleOverlayClick } = useDrawer(props, emit); + return () => ( + + {props.showOverlay && } + + {props.modelValue && ( +
    + {slots.default?.()} +
    + )} +
    - ) - } -}) + ); + }, +}); diff --git a/packages/devui-vue/devui/drawer/src/use-drawer.ts b/packages/devui-vue/devui/drawer/src/use-drawer.ts new file mode 100644 index 0000000000..f328cc1201 --- /dev/null +++ b/packages/devui-vue/devui/drawer/src/use-drawer.ts @@ -0,0 +1,50 @@ +import { computed, onUnmounted, ref, watch } from 'vue'; +import { onClickOutside } from '@vueuse/core'; +import { DrawerEmit, DrawerProps, UseDrawerFn } from './drawer-types'; +import { lockScroll } from '../../shared/util/lock-scroll'; + +export function useDrawer(props: DrawerProps, emit: DrawerEmit): UseDrawerFn { + const drawerRef = ref(); + const drawerClasses = computed(() => ({ + 'devui-drawer': true, + [`devui-drawer-${props.position}`]: true, + })); + const close = () => { + emit('update:modelValue', false); + emit('close'); + }; + let lockScrollCb: () => void; + const execClose = () => { + props.beforeClose ? props.beforeClose(close) : close(); + }; + const handleOverlayClick = () => { + props.closeOnClickOverlay && execClose(); + }; + const handleEscClose = (e: KeyboardEvent) => { + e.code === 'Escape' && execClose(); + }; + + onClickOutside(drawerRef, execClose); + + const removeBodyAdditions = () => { + lockScrollCb?.(); + document.removeEventListener('keyup', handleEscClose); + }; + + watch( + () => props.modelValue, + (val) => { + if (val) { + emit('open'); + props.lockScroll && (lockScrollCb = lockScroll()); + props.escKeyCloseable && document.addEventListener('keyup', handleEscClose); + } else { + removeBodyAdditions(); + } + } + ); + + onUnmounted(removeBodyAdditions); + + return { drawerRef, drawerClasses, handleOverlayClick }; +} diff --git a/packages/devui-vue/devui/dropdown/index.ts b/packages/devui-vue/devui/dropdown/index.ts index 0075b1cbba..a99d4509c3 100644 --- a/packages/devui-vue/devui/dropdown/index.ts +++ b/packages/devui-vue/devui/dropdown/index.ts @@ -1,18 +1,16 @@ -import type { App } from 'vue' +import type { App } from 'vue'; +import Dropdown from './src/dropdown'; +import DropdownMenu from './src/dropdown-menu'; +export * from './src/dropdown-menu-types'; -import Dropdown from './src/dropdown' - -Dropdown.install = function (app: App): void { - app.component(Dropdown.name, Dropdown) -} - -export { Dropdown } +export { Dropdown, DropdownMenu }; export default { title: 'Dropdown 下拉菜单', category: '导航', - status: '10%', // TODO: 组件若开发完成则填入"已完成",并删除该注释 + status: '10%', install(app: App): void { - app.use(Dropdown as any) - } -} + app.component(Dropdown.name, Dropdown); + app.component(DropdownMenu.name, DropdownMenu); + }, +}; diff --git a/packages/devui-vue/devui/dropdown/src/dropdown-directive.ts b/packages/devui-vue/devui/dropdown/src/dropdown-directive.ts deleted file mode 100644 index 38d67100b7..0000000000 --- a/packages/devui-vue/devui/dropdown/src/dropdown-directive.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const dDropdownDirective = { - -}; - -export const dDropdownMenuDirective = { - -}; diff --git a/packages/devui-vue/devui/dropdown/src/dropdown-menu-types.ts b/packages/devui-vue/devui/dropdown/src/dropdown-menu-types.ts new file mode 100644 index 0000000000..ac89d8711a --- /dev/null +++ b/packages/devui-vue/devui/dropdown/src/dropdown-menu-types.ts @@ -0,0 +1,47 @@ +import type { PropType, ExtractPropTypes } from 'vue'; + +export type CloseScopeArea = 'all' | 'blank' | 'none'; +export type Placement = + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-start' + | 'top-end' + | 'right-start' + | 'right-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end'; +export type Alignment = 'start' | 'end'; +export type OffsetOptions = { mainAxis?: number; crossAxis?: number }; + +export const dropdownMenuProps = { + modelValue: { + type: Boolean, + default: false, + }, + origin: { + type: Object as PropType, + require: true, + }, + position: { + type: Array as PropType>, + default: ['bottom'], + }, + align: { + type: String as PropType | null, + default: null, + }, + offset: { + type: [Number, Object] as PropType, + default: 4, + }, + clickOutside: { + type: Function as PropType<() => boolean>, + default: (): boolean => true, + }, +}; + +export type DropdownMenuProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/dropdown/src/dropdown-menu.tsx b/packages/devui-vue/devui/dropdown/src/dropdown-menu.tsx new file mode 100644 index 0000000000..34c6de7756 --- /dev/null +++ b/packages/devui-vue/devui/dropdown/src/dropdown-menu.tsx @@ -0,0 +1,48 @@ +import { defineComponent, ref, toRefs, Transition, Teleport, computed } from 'vue'; +import { onClickOutside } from '@vueuse/core'; +import { FlexibleOverlay } from '../../overlay'; +import { dropdownMenuProps, DropdownMenuProps } from './dropdown-menu-types'; + +export default defineComponent({ + name: 'DDropdownMenu', + inheritAttrs: false, + props: dropdownMenuProps, + emits: ['update:modelValue'], + setup(props: DropdownMenuProps, { slots, attrs, emit }) { + const { modelValue, origin, position, align, offset, clickOutside } = toRefs(props); + const dropdownMenuRef = ref(null); + + onClickOutside(dropdownMenuRef, (value) => { + if (clickOutside.value?.() && !origin.value.contains(value.target)) { + emit('update:modelValue', false); + } + }); + + const currentPosition = ref('bottom'); + const handlePositionChange = (pos) => { + currentPosition.value = pos.split('-')[0] === 'top' ? 'top' : 'bottom'; + }; + const styles = computed(() => ({ + transformOrigin: currentPosition.value === 'top' ? '0% 100%' : '0% 0%', + })); + + return () => ( + + + +
    + {slots.default?.()} +
    +
    +
    +
    + ); + }, +}); diff --git a/packages/devui-vue/devui/dropdown/src/dropdown-types.ts b/packages/devui-vue/devui/dropdown/src/dropdown-types.ts index 706048dfb4..b0eca132ad 100644 --- a/packages/devui-vue/devui/dropdown/src/dropdown-types.ts +++ b/packages/devui-vue/devui/dropdown/src/dropdown-types.ts @@ -1,43 +1,65 @@ -import type { PropType, ExtractPropTypes, ComponentPublicInstance } from 'vue' +import type { PropType, ExtractPropTypes, Ref } from 'vue'; export type TriggerType = 'click' | 'hover' | 'manually'; export type CloseScopeArea = 'all' | 'blank' | 'none'; +export type Placement = + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-start' + | 'top-end' + | 'right-start' + | 'right-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end'; +export type Alignment = 'start' | 'end'; +export type OffsetOptions = { mainAxis?: number; crossAxis?: number }; -export const dropdownProps = { - origin: { - type: Object as PropType - }, +type ReadonlyRef = Readonly>; - isOpen: { - type: Boolean, - default: false - }, +export type EmitEvent = (event: 'toggle', result: boolean) => void; - disabled: { +export const dropdownProps = { + visible: { type: Boolean, - default: false + default: false, }, - trigger: { type: String as PropType, - default: 'click' + default: 'click', }, - closeScope: { type: String as PropType, - default: 'all' + default: 'all', + }, + position: { + type: Array as PropType>, + default: ['bottom'], + }, + align: { + type: String as PropType | null, + default: null, + }, + offset: { + type: [Number, Object] as PropType, + default: 4, }, - closeOnMouseLeaveMenu: { type: Boolean, - default: false + default: false, }, +}; - showAnimation: { - type: Boolean, - default: true - } - -} as const +export type DropdownProps = ExtractPropTypes; -export type DropdownProps = ExtractPropTypes +export interface UseDropdownProps { + id: string; + isOpen: Ref; + origin: ReadonlyRef; + dropdownRef: ReadonlyRef; + props: DropdownProps; + emit: EmitEvent; +} diff --git a/packages/devui-vue/devui/dropdown/src/dropdown.scss b/packages/devui-vue/devui/dropdown/src/dropdown.scss index faa3cd509d..78c93a6141 100644 --- a/packages/devui-vue/devui/dropdown/src/dropdown.scss +++ b/packages/devui-vue/devui/dropdown/src/dropdown.scss @@ -1,4 +1,8 @@ -@import '../../styles-var/devui-var'; +@import '../../styles-var/devui-var.scss'; + +.devui-dropdown-toggle { + display: inline-block; +} .devui-dropdown span { &.icon-chevron-down, @@ -8,6 +12,10 @@ } } +.devui-dropdown-menu-wrap .devui-dropdown-menu { + width: 100%; +} + .devui-dropdown-animation span { &.icon-chevron-down, &.icon-select-arrow { @@ -22,40 +30,80 @@ } } +.fade-in-top { + animation: fadeInTop 0.2s cubic-bezier(0.16, 0.75, 0.5, 1); +} +.fade-in-bottom { + animation: fadeInBottom 0.2s cubic-bezier(0.16, 0.75, 0.5, 1); +} -.devui-dropdown-fade { - @mixin d-dropdown-fade-animation { - animation-name: d-dropdown-fade; - animation-duration: 0.3s; +@keyframes fadeInTop { + from { + opacity: 0.8; + transform: scaleY(0.8) translateY(4px); } - @keyframes d-dropdown-fade { - 0% { - opacity: 0; - transform: scaleY(0.9999) scaleY(0); - } - 100% { - opacity: 1; - transform: scaleY(0.8) scaleY(4px); - } + to { + opacity: 1; + transform: scaleY(0.9999) translateY(0); } +} - &-enter { - opacity: 0; +@keyframes fadeInBottom { + from { + opacity: 0.8; + transform: scaleY(0.8) translateY(-4px); } - &-enter-active { - @include d-dropdown-fade-animation; + to { + opacity: 1; + transform: scaleY(0.9999) translateY(0); } +} - &-leave { - opacity: 1; +.devui-dropdown-fade { + &-bottom { + &-enter-from, + &-leave-to { + opacity: 0.8; + transform: scaleY(0.8) translateY(-4px); + } + + &-enter-to, + &-leave-from { + opacity: 1; + transform: scaleY(0.9999) translateY(0); + } + + &-enter-active { + transition: transform 0.2s cubic-bezier(0.16, 0.75, 0.5, 1), opacity 0.2s cubic-bezier(0.16, 0.75, 0.5, 1); + } + + &-leave-active { + transition: transform 0.2s cubic-bezier(0.5, 0, 0.84, 0.25), opacity 0.2s cubic-bezier(0.5, 0, 0.84, 0.25); + } } - &-leave-active { - @include d-dropdown-fade-animation; + &-top { + &-enter-from, + &-leave-to { + opacity: 0.8; + transform: scaleY(0.8) translateY(4px); + } + + &-enter-to, + &-leave-from { + opacity: 1; + transform: scaleY(0.9999) translateY(0); + } - animation-direction: reverse; + &-enter-active { + transition: transform 0.2s cubic-bezier(0.16, 0.75, 0.5, 1), opacity 0.2s cubic-bezier(0.16, 0.75, 0.5, 1); + } + + &-leave-active { + transition: transform 0.2s cubic-bezier(0.5, 0, 0.84, 0.25), opacity 0.2s cubic-bezier(0.5, 0, 0.84, 0.25); + } } -} \ No newline at end of file +} diff --git a/packages/devui-vue/devui/dropdown/src/dropdown.tsx b/packages/devui-vue/devui/dropdown/src/dropdown.tsx index 5dd8d6ba64..53f2895fb7 100644 --- a/packages/devui-vue/devui/dropdown/src/dropdown.tsx +++ b/packages/devui-vue/devui/dropdown/src/dropdown.tsx @@ -1,66 +1,65 @@ -import { defineComponent, watch, ref, toRefs, Transition, computed } from 'vue' -import { dropdownProps, DropdownProps } from './dropdown-types' -import { useDropdown } from './use-dropdown'; - +import { defineComponent, ref, toRefs, Transition, Teleport, computed } from 'vue'; +import { dropdownProps, DropdownProps } from './dropdown-types'; +import { useDropdown, useDropdownEvent } from './use-dropdown'; import { FlexibleOverlay } from '../../overlay'; +import './dropdown.scss'; -import './dropdown.scss' +let dropdownId = 1; export default defineComponent({ name: 'DDropdown', + inheritAttrs: false, props: dropdownProps, - emits: [], - setup(props: DropdownProps, ctx) { - const { + emits: ['toggle'], + setup(props: DropdownProps, { slots, attrs, emit }) { + const { visible, position, align, offset } = toRefs(props); + const origin = ref(); + const dropdownRef = ref(); + const id = `dropdown_${dropdownId++}`; + const isOpen = ref(false); + const currentPosition = ref('bottom'); + const handlePositionChange = (pos) => { + currentPosition.value = pos.includes('top') || pos.includes('end') ? 'top' : 'bottom'; + }; + const styles = computed(() => ({ + transformOrigin: currentPosition.value === 'top' ? '0% 100%' : '0% 0%', + })); + const classes = computed(() => ({ + 'fade-in-bottom': isOpen.value && currentPosition.value === 'bottom', + 'fade-in-top': isOpen.value && currentPosition.value === 'top', + })); + useDropdownEvent({ + id, isOpen, origin, - trigger, - closeScope, - closeOnMouseLeaveMenu, - } = toRefs(props); - - const visible = ref(false); - watch(isOpen, (value) => { - visible.value = value; - }, { immediate: true }); - - const position = { - originX: 'center', - originY: 'bottom', - overlayX: 'center', - overlayY: 'top' - } as const; - - const { dropdownEl } = useDropdown({ - visible, - origin, - trigger, - closeScope, - closeOnMouseLeaveMenu, - }); - - const animatedVisible = computed(() => { - return props.showAnimation ? visible.value : true; + dropdownRef, + props, + emit, }); - - return () => { - // let vnodes = ctx.slots.default?.() ?? []; - return ( - <> - - -
    - {ctx.slots.default?.()} + useDropdown(id, visible, isOpen, origin, dropdownRef, currentPosition, emit); + return () => ( + <> +
    + {slots.default?.()} +
    + + + +
    + {slots.menu?.()}
    -
    - - - ) - }; - } -}) + + +
    + + ); + }, +}); diff --git a/packages/devui-vue/devui/dropdown/src/use-dropdown.ts b/packages/devui-vue/devui/dropdown/src/use-dropdown.ts index 596a457931..0bae60f6f0 100644 --- a/packages/devui-vue/devui/dropdown/src/use-dropdown.ts +++ b/packages/devui-vue/devui/dropdown/src/use-dropdown.ts @@ -1,120 +1,157 @@ -import { Ref, ref, watch } from 'vue'; +import { watch, onMounted, onUnmounted, toRefs } from 'vue'; +import type { Ref } from 'vue'; import { getElement } from '../../shared/util/dom'; -import { CloseScopeArea, TriggerType } from './dropdown-types'; +import { UseDropdownProps, EmitEvent } from './dropdown-types'; -function subscribeEvent(dom: Element | Document, type: string, callback: (event: E) => void) { - dom?.addEventListener(type, callback as any); - return () => { - dom?.removeEventListener(type, callback as any); - } -} +const dropdownMap = new Map(); -type ReadonlyRef = Readonly>; - -interface UseDropdownProps { - visible: Ref - trigger: ReadonlyRef - origin: ReadonlyRef - closeScope: ReadonlyRef - closeOnMouseLeaveMenu: ReadonlyRef -} - -interface UseDropdownResult { - dropdownEl: Ref +function subscribeEvent(dom: Element | Document, type: string, callback: (event: any) => void) { + dom?.addEventListener(type, callback); + return () => { + dom?.removeEventListener(type, callback); + }; } -export const useDropdown = ({ - visible, - trigger, - origin, - closeScope, - closeOnMouseLeaveMenu -}: UseDropdownProps): UseDropdownResult => { - const dropdownElRef = ref(); - - const closeByScope = () => { - if (closeScope.value === 'none') { +export const useDropdownEvent = ({ id, isOpen, origin, dropdownRef, props, emit }: UseDropdownProps): void => { + let overlayEnter = false; + let originEnter = false; + const { trigger, closeScope, closeOnMouseLeaveMenu } = toRefs(props); + const toggle = (status: boolean) => { + isOpen.value = status; + emit('toggle', isOpen.value); + }; + const handleLeave = async (elementType: 'origin' | 'dropdown', e?) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + if ((elementType === 'origin' && overlayEnter) || (elementType === 'dropdown' && originEnter)) { return; } - visible.value = false; - } - watch( - [trigger, origin, dropdownElRef], - ([trigger, origin, dropdownEl], ov, onInvalidate) => { - const originEl = getElement(origin); - if (!originEl || !dropdownEl) { - return; - } - const subscriptions = [ - subscribeEvent(dropdownEl, 'click', () => { - if (closeScope.value === 'all') { - visible.value = false; + if (e) { + [...dropdownMap.values()].reverse().forEach((item) => { + setTimeout(() => { + item.toggle?.(); + }, 0); + }); + } + toggle(false); + }; + watch([trigger, origin, dropdownRef], ([triggerVal, originVal, dropdownEl], ov, onInvalidate) => { + const originEl = getElement(originVal); + const subscriptions = []; + setTimeout(() => { + subscriptions.push( + subscribeEvent(document, 'click', (e: Event) => { + const dropdownValues = [...dropdownMap.values()]; + if ( + !isOpen.value || + closeScope.value === 'none' || + (dropdownEl.contains(e.target) && closeScope.value === 'blank') || + (dropdownValues.some((item) => item.toggleEl?.contains(e.target)) && + dropdownValues.some((item) => item.menuEl?.contains(e.target))) + ) { + return; + } + [...dropdownMap.values()].reverse().forEach((item) => { + setTimeout(() => { + if (!item.toggleEl?.contains(e.target)) { + item.toggle?.(); + } + }, 0); + }); + overlayEnter = false; + }) + ); + }, 0); + if (triggerVal === 'click') { + subscriptions.push( + subscribeEvent(originEl, 'click', () => toggle(!isOpen.value)), + subscribeEvent(dropdownEl, 'mouseleave', (e: MouseEvent) => { + if (closeOnMouseLeaveMenu.value && !dropdownMap.get(id).child?.contains(e.relatedTarget)) { + handleLeave('dropdown', e); } + }) + ); + } else if (triggerVal === 'hover') { + subscriptions.push( + subscribeEvent(originEl, 'mouseenter', () => { + originEnter = true; + toggle(true); }), - ]; - - if (trigger === 'click') { - // 点击触发 - subscriptions.push( - subscribeEvent(originEl, 'click', () => visible.value = !visible.value), - subscribeEvent(document, 'click', (e) => { - if (!visible.value) { - return; - } - const target = e.target as HTMLElement; - const isContain = originEl.contains(target) || dropdownEl.contains(target); - if (isContain) { - return; - } - closeByScope(); - }), - subscribeEvent(dropdownEl, 'mouseleave', () => { - // 判断鼠标是否已经进入 origin - if (closeOnMouseLeaveMenu.value) { - visible.value = false; - } - }) - ); - } else if (trigger === 'hover') { - // 鼠标悬浮触发 - let overlayEnter = false; - let originEnter = false; - const handleLeave = async (elementType: 'origin' | 'dropdown') => { - // 由于关联元素和 dropdown 元素间有间距, - // 悬浮时在两者之间移动可能会导致多次关闭打开, - // 所以需要给关闭触发节流。 - await new Promise((resolve) => setTimeout(resolve, 50)); - if ((elementType === 'origin' && overlayEnter) || (elementType === 'dropdown' && originEnter)) { + subscribeEvent(originEl, 'mouseleave', () => { + originEnter = false; + handleLeave('origin'); + }), + subscribeEvent(dropdownEl, 'mouseenter', () => { + overlayEnter = true; + isOpen.value = true; + }), + subscribeEvent(dropdownEl, 'mouseleave', (e: MouseEvent) => { + overlayEnter = false; + if (e.relatedTarget && (originEl?.contains(e.relatedTarget) || dropdownMap.get(id).child?.contains(e.relatedTarget))) { return; } - closeByScope(); - }; - subscriptions.push( - subscribeEvent(originEl, 'mouseenter', () => { - originEnter = true; - visible.value = true; - }), - subscribeEvent(originEl, 'mouseleave', () => { - originEnter = false; - // 判断鼠标是否已经进入 overlay - if (!closeOnMouseLeaveMenu.value) { - handleLeave('origin'); - } - }), - subscribeEvent(dropdownEl, 'mouseenter', () => { - overlayEnter = true; - visible.value = true; - }), - subscribeEvent(dropdownEl, 'mouseleave', () => { - overlayEnter = false; - // 判断鼠标是否已经进入 origin - handleLeave('dropdown'); - }) - ); - } - onInvalidate(() => subscriptions.forEach(v => v())); + handleLeave('dropdown', e); + }) + ); } - ); + onInvalidate(() => subscriptions.forEach((v) => v())); + }); +}; - return { dropdownEl: dropdownElRef }; +export function useDropdown( + id: string, + visible: Ref, + isOpen: Ref, + origin: Ref, + dropdownRef: Ref, + popDirection: Ref, + emit: EmitEvent +): void { + const calcPopDirection = (dropdownEl: HTMLElement) => { + const elementHeight = dropdownEl.offsetHeight; + const bottomDistance = window.innerHeight - origin.value.getBoundingClientRect().bottom; + const isBottomEnough = bottomDistance >= elementHeight; + if (!isBottomEnough) { + popDirection.value = 'top'; + } else { + popDirection.value = 'bottom'; + } + }; + + watch( + visible, + (newVal, oldVal) => { + if (oldVal === undefined) { + return; + } + isOpen.value = newVal; + emit('toggle', isOpen.value); + }, + { immediate: true } + ); + watch([isOpen, dropdownRef], ([isOpenVal, dropdownEl]) => { + if (isOpenVal) { + dropdownMap.set(id, { + ...dropdownMap.get(id), + menuEl: dropdownEl, + toggle: () => { + isOpen.value = false; + emit('toggle', isOpen.value); + }, + }); + for (const value of dropdownMap.values()) { + if (value.menuEl?.contains(origin.value)) { + value.child = dropdownEl; + } + } + } + if (dropdownEl) { + calcPopDirection(dropdownEl); + } + }); + onMounted(() => { + dropdownMap.set(id, { toggleEl: origin.value }); + }); + onUnmounted(() => { + dropdownMap.delete(id); + }); } diff --git a/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts b/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts index aa694a308b..2a754c3677 100644 --- a/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts +++ b/packages/devui-vue/devui/editable-select/__tests__/editable-select.spec.ts @@ -1,8 +1,287 @@ import { mount } from '@vue/test-utils'; +import { reactive, ref } from 'vue'; import { EditableSelect } from '../index'; +const createData = (len = 5) => { + return reactive( + Array.from({ length: len }).map((_, index) => { + return { + label: `label${index}`, + value: index + }; + }) + ); +}; describe('editable-select test', () => { - it('editable-select init render', async () => { - // todo - }) -}) + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('create', () => { + const wrapper = mount(EditableSelect); + + expect(wrapper.find('.devui-editable-select').exists()).toBe(true); + }); + + test('should render correctly', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ''; + const options = createData(); + return { + value, + options + }; + } + }); + + const input = wrapper.find('input'); + + expect(wrapper.get('.devui-dropdown-item').isVisible()).toBe(false); + + await input.trigger('click'); + + expect(wrapper.get('.devui-dropdown-item').isVisible()).toBe(true); + expect(wrapper.classes()).toContain('devui-select-open'); + + const options = wrapper.element.querySelectorAll('.devui-dropdown-item'); + + expect(options.length).toBe(5); + }); + + test('select on click ', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const options = wrapper.findAll('.devui-dropdown-item'); + + await options[0].trigger('click'); + + expect(wrapper.find('input').element.value).toBe('label0'); + }); + + test('disabled select', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: `` + }); + expect(wrapper.find('input').element.disabled).toBe(true); + }); + + test('disabled option', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = reactive([ + { + label: 'label0', + value: 0 + }, + { + label: 'label1', + value: 1, + disabled: true + }, + { + label: 'label2', + value: 2, + disabled: false + } + ]); + return { + value, + options + }; + } + }); + const options = wrapper.findAll('.devui-dropdown-item'); + + expect(options[1].classes()).toContain('disabled'); + + await options[1].trigger('click'); + + expect(wrapper.find('input').element.value).toBe(''); + + await options[2].trigger('click'); + + expect(wrapper.find('input').element.value).toBe('label2'); + }); + + test('search', async () => { + const handleSearch = jest.fn(); + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options, + handleSearch + }; + } + }); + const input = wrapper.find('input'); + await input.setValue('label'); + await input.trigger('input'); + expect(handleSearch).toBeCalled(); + }); + + test('filter option', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const input = wrapper.find('input'); + await input.setValue('label0'); + await input.trigger('input'); + expect(wrapper.findAll('.devui-dropdown-item').length).toBe(1); + }); + + test('custom filter options', async () => { + const filterOption = jest.fn(); + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options, + filterOption + }; + } + }); + const input = wrapper.find('input'); + await input.setValue('label0'); + await input.trigger('input'); + expect(filterOption).toBeCalled(); + }); + + test('render slot', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ` + + + `, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const input = wrapper.find('input'); + const options = wrapper.findAll('.devui-dropdown-item'); + expect(options.length).toBe(5); + await input.setValue('aaa'); + await input.trigger('input'); + expect(wrapper.find('#noResultItemTemplate').exists()).toBe(true); + }); + + test('load more ', async () => { + const loadmore = jest.fn(); + const makeScroll = async (dom: Element, name: 'scrollTop', offset: number) => { + const eventTarget = dom === document.documentElement ? window : dom; + dom[name] = offset; + const evt = new CustomEvent('scroll', { + detail: { + target: { + [name]: offset + } + } + }); + eventTarget.dispatchEvent(evt); + }; + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(20); + return { + value, + options, + handleLoad: loadmore + }; + } + }); + const ul = wrapper.find('.devui-list-unstyled'); + await makeScroll(ul.element, 'scrollTop', 300); + expect(loadmore).toBeCalled(); + }); + test('keyboard operations', async () => { + const wrapper = mount({ + components: { + 'editable-select': EditableSelect + }, + template: ``, + setup() { + const value = ref(''); + const options = createData(); + return { + value, + options + }; + } + }); + const input = wrapper.find('input'); + await input.trigger('click'); + await input.trigger('keydown', { key: 'ArrowDown' }); + await input.trigger('keydown', { key: 'ArrowDown' }); + await input.trigger('keydown', { key: 'Enter' }); + expect(input.element.value).toBe('label2'); + }); +}); diff --git a/packages/devui-vue/devui/editable-select/index.ts b/packages/devui-vue/devui/editable-select/index.ts index de0ccd55ba..3468c0868e 100644 --- a/packages/devui-vue/devui/editable-select/index.ts +++ b/packages/devui-vue/devui/editable-select/index.ts @@ -1,16 +1,17 @@ -import type { App } from 'vue' -import EditableSelect from './src/editable-select' +import type { App } from 'vue'; +import EditableSelect from './src/editable-select'; + EditableSelect.install = function (app: App): void { - app.component(EditableSelect.name, EditableSelect) -} + app.component(EditableSelect.name, EditableSelect); +}; -export { EditableSelect } +export { EditableSelect }; export default { title: 'EditableSelect 可输入下拉选择框', category: '数据录入', - status: '10%', // TODO: 组件若开发完成则填入"已完成",并删除该注释 + status: '100%', install(app: App): void { - app.use(EditableSelect as any) + app.use(EditableSelect as any); } -} +}; diff --git a/packages/devui-vue/devui/editable-select/src/components/dropdown.tsx b/packages/devui-vue/devui/editable-select/src/components/dropdown.tsx deleted file mode 100644 index 7f1f029bad..0000000000 --- a/packages/devui-vue/devui/editable-select/src/components/dropdown.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { defineComponent, inject } from 'vue' -import { OptionItem, selectDropdownProps } from '../editable-select-types' -import { className } from '../utils' -export default defineComponent({ - name: 'DSelectDropdown', - props: selectDropdownProps, - setup(props) { - const select = inject('InjectionKey') as any - const { - props: selectProps, - dropdownRef, - visible, - selectOptionClick, - renderDefaultSlots, - renderEmptySlots, - selectedIndex, - hoverIndex, - loadMore - } = select - const { maxHeight } = selectProps - return () => { - const getLiCls = (item: OptionItem, index: number) => { - const { disabledKey } = selectProps - return className('devui-dropdown-item', { - disabled: disabledKey ? !!item[disabledKey] : false, - selected: selectedIndex.value === index, - 'devui-dropdown-bg': hoverIndex.value === index - }) - } - return ( -
    -
      - {props.options.map((o, index) => { - return ( -
    • selectOptionClick($evnet, o)}> - {renderDefaultSlots(o)} -
    • - ) - })} -
    • -
      {renderEmptySlots()}
      -
    • -
    -
    - ) - } - } -}) diff --git a/packages/devui-vue/devui/editable-select/src/composable/use-keyBoard-select.ts b/packages/devui-vue/devui/editable-select/src/composable/use-keyBoard-select.ts deleted file mode 100644 index 3d9c59f96c..0000000000 --- a/packages/devui-vue/devui/editable-select/src/composable/use-keyBoard-select.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ComputedRef, nextTick, Ref } from 'vue' -import { OptionItem } from '../editable-select-types' -interface keyBoardSelectReturnType { - handleKeydown: (e: KeyboardEvent) => void -} - -export default function keyboardSelect( - dropdownRef: Ref, - visible: Ref, - hoverIndex: Ref, - selectedIndex: Ref, - filteredOptions: ComputedRef, - toggleMenu: () => void, - selectOptionClick: (e: KeyboardEvent, item: OptionItem) => void -): keyBoardSelectReturnType { - const updateHoverIndex = (index: number) => { - hoverIndex.value = index - } - const scrollToActive = (index: number) => { - const dropdownVal = dropdownRef.value - const li = dropdownVal.children[index] - - nextTick(() => { - if (li.scrollIntoViewIfNeeded) { - li.scrollIntoViewIfNeeded(false) - } else { - const containerInfo = dropdownVal.getBoundingClientRect() - const elementInfo = li.getBoundingClientRect() - if (elementInfo.bottom > containerInfo.bottom || elementInfo.top < containerInfo.top) { - li.scrollIntoView(false) - } - } - }) - } - const onKeyboardSelect = (e: KeyboardEvent) => { - const option = filteredOptions.value[hoverIndex.value] - selectOptionClick(e, option) - hoverIndex.value = selectedIndex.value - } - const handleKeydown = (e: KeyboardEvent) => { - const keyCode = e.key || e.code - let index = 0 - if (!visible.value) { - toggleMenu() - } - - if (keyCode === 'Backspace') { - return - } - - if (keyCode === 'ArrowUp') { - index = hoverIndex.value - 1 - if (index < 0) { - index = filteredOptions.value.length - 1 - } - } else if (keyCode === 'ArrowDown') { - index = hoverIndex.value + 1 - if (index > filteredOptions.value.length - 1) { - index = 0 - } - } - - if (keyCode === 'Enter') { - return onKeyboardSelect(e) - } - updateHoverIndex(index) - scrollToActive(index) - } - - return { - handleKeydown - } -} diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-filter-options.ts b/packages/devui-vue/devui/editable-select/src/composables/use-filter-options.ts new file mode 100644 index 0000000000..87c2e3514a --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-filter-options.ts @@ -0,0 +1,28 @@ +import { computed, Ref, ComputedRef } from 'vue'; +import { OptionObjectItem } from '../editable-select-type'; + +const getFilterFunc = () => (val: string, option: OptionObjectItem) => + option.label.toLocaleLowerCase().indexOf(val.toLocaleLowerCase()) > -1; + +export const userFilterOptions: ( + normalizeOptions: ComputedRef, + inputValue: Ref, + filteredOptions: boolean | ((val: string, option: OptionObjectItem) => boolean) +) => ComputedRef = (normalizeOptions, inputValue, filterOption) => + computed(() => { + const filteredOptions: OptionObjectItem[] = []; + + if (!inputValue.value || filterOption === false) { + return normalizeOptions.value; + } + + const filterFunc = typeof filterOption === 'function' ? filterOption : getFilterFunc(); + + normalizeOptions.value.forEach((option) => { + if (filterFunc(inputValue.value, option)) { + filteredOptions.push(option); + } + }); + + return filteredOptions; + }); diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-input.ts b/packages/devui-vue/devui/editable-select/src/composables/use-input.ts new file mode 100644 index 0000000000..d553ad362a --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-input.ts @@ -0,0 +1,22 @@ +import { SetupContext, Ref } from 'vue'; +interface userInputReturnType { + handleInput: (event: Event) => void +} +export const useInput: (inputValue: Ref, ctx: SetupContext) => userInputReturnType = ( + inputValue, + ctx +) => { + const onInputChange = (value: string) => { + ctx.emit('search', value); + }; + + const handleInput = (event: Event) => { + const value = (event.target as HTMLInputElement).value; + inputValue.value = value; + onInputChange(value); + }; + + return { + handleInput + }; +}; diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-keyboard-select.ts b/packages/devui-vue/devui/editable-select/src/composables/use-keyboard-select.ts new file mode 100644 index 0000000000..45e6760d36 --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-keyboard-select.ts @@ -0,0 +1,110 @@ +import { ComputedRef, nextTick, Ref } from 'vue'; +import { OptionObjectItem } from '../editable-select-type'; + +interface useKeyboardSelectReturnType { + handleKeydown: (event: KeyboardEvent) => void +} +export const useKeyboardSelect: ( + dropdownRef: Ref, + disabled: string, + visible: Ref, + hoverIndex: Ref, + selectedIndex: Ref, + options: ComputedRef, + toggleMenu: () => void, + closeMenu: () => void, + handleClick: (options: OptionObjectItem) => void +) => useKeyboardSelectReturnType = ( + dropdownRef, + disabled, + visible, + hoverIndex, + selectedIndex, + options, + toggleMenu, + closeMenu, + handleClick +) => { + const updateHoveringIndex = (index: number) => { + hoverIndex.value = index; + }; + const scrollToItem = (index: number) => { + const ul = dropdownRef.value; + const li = ul.children[index]; + nextTick(() => { + if (li.scrollIntoViewIfNeeded) { + li.scrollIntoViewIfNeeded(false); + } else { + const containerInfo = ul.getBoundingClientRect(); + const elementInfo = li.getBoundingClientRect(); + if (elementInfo.bottom > containerInfo.bottom || elementInfo.top < containerInfo.top) { + li.scrollIntoView(false); + } + } + }); + }; + + const onKeyboardNavigation = (direction: string, newIndex?: number) => { + if (!newIndex) { + newIndex = hoverIndex.value; + } + if (!['ArrowDown', 'ArrowUp'].includes(direction)) return; + if (direction === 'ArrowUp') { + if (newIndex === 0) { + newIndex = options.value.length - 1; + scrollToItem(newIndex); + updateHoveringIndex(newIndex); + return; + } + newIndex = newIndex - 1; + } else if (direction === 'ArrowDown') { + if (newIndex === options.value.length - 1) { + newIndex = 0; + scrollToItem(newIndex); + updateHoveringIndex(newIndex); + return; + } + newIndex = newIndex + 1; + } + + const option = options.value[newIndex]; + if (option[disabled]) { + return onKeyboardNavigation(direction, newIndex); + } + scrollToItem(newIndex); + updateHoveringIndex(newIndex); + }; + + const handleKeydown = (event: KeyboardEvent) => { + const keyCode = event.key || event.code; + + if (options.value.length === 0) return; + + if (!visible.value) { + return toggleMenu(); + } + + const onKeydownEnter = () => { + handleClick(options.value[hoverIndex.value]); + closeMenu(); + }; + + const onKeydownEsc = () => { + closeMenu(); + }; + + switch (keyCode) { + case 'Enter': + onKeydownEnter(); + break; + case 'Escape': + onKeydownEsc(); + break; + default: + onKeyboardNavigation(keyCode); + } + }; + return { + handleKeydown + }; +}; diff --git a/packages/devui-vue/devui/editable-select/src/composables/use-lazy-load.ts b/packages/devui-vue/devui/editable-select/src/composables/use-lazy-load.ts new file mode 100644 index 0000000000..2580eaf9ae --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/composables/use-lazy-load.ts @@ -0,0 +1,25 @@ +import { Ref } from 'vue'; +import { OptionObjectItem } from '../editable-select-type'; + +interface useLazyLoadReturenType { + loadMore: () => void +} +export const useLazyLoad: ( + dropdownRef: Ref, + inputValue: Ref, + filterOtion: boolean | ((val: string, option: OptionObjectItem) => boolean), + load: (val: string) => void +) => useLazyLoadReturenType = (dropdownRef, inputValue, filterOtion, load) => { + const loadMore = () => { + if (filterOtion !== false) return; + + if ( + dropdownRef.value.clientHeight + dropdownRef.value.scrollTop >= + dropdownRef.value.scrollHeight + ) { + load(inputValue.value); + } + }; + + return { loadMore }; +}; diff --git a/packages/devui-vue/devui/editable-select/src/editable-select-type.ts b/packages/devui-vue/devui/editable-select/src/editable-select-type.ts new file mode 100644 index 0000000000..87a4508326 --- /dev/null +++ b/packages/devui-vue/devui/editable-select/src/editable-select-type.ts @@ -0,0 +1,7 @@ +export type OptionsType = Array; +export type OptionType = string | number | OptionObjectItem; +export interface OptionObjectItem { + label: string + value: string | number + [key: string]: any +} diff --git a/packages/devui-vue/devui/editable-select/src/editable-select-types.ts b/packages/devui-vue/devui/editable-select/src/editable-select-types.ts index 2fb55f9525..8ee593c50d 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select-types.ts +++ b/packages/devui-vue/devui/editable-select/src/editable-select-types.ts @@ -1,74 +1,47 @@ -import type { PropType, ExtractPropTypes } from 'vue' -type HorizontalConnectionPos = 'left' | 'center' | 'right'; -type VerticalConnectionPos = 'top' | 'center' | 'bottom'; - -export interface ConnectionPosition { - originX: HorizontalConnectionPos - originY: VerticalConnectionPos - overlayX: HorizontalConnectionPos - overlayY: VerticalConnectionPos -} -export interface OptionItem { - name: string - [key: string]: any -} -export type Options = Array +import type { PropType, ExtractPropTypes } from 'vue'; +import { OptionObjectItem, OptionsType } from './editable-select-type'; export const editableSelectProps = { + /* test: { + type: Object as PropType<{ xxx: xxx }> + } */ appendToBody: { - type: Boolean, - default: false - }, - modelValue: { - type: [String, Number] as PropType + type: Boolean }, options: { - type: Array as PropType, + type: Array as PropType, default: () => [] }, - width: { - type: Number, - default: 450 - }, - maxHeight: { - type: Number - }, disabled: { - type: Boolean, - default: false - }, - disabledKey: { - type: String, - }, - remote: { - type: Boolean, - default: false + type: Boolean }, loading: { type: Boolean }, - enableLazyLoad: { - type: Boolean, - default: false + optionDisabledKey: { + type: String, + default: '' + }, + placeholder: { + type: String, + default: 'Search' }, - remoteMethod: { - type: Function as PropType<(inputValue: string) => Array> + modelValue: { + type: String + }, + width: { + type: Number }, - filterMethod: { - type: Function as PropType<(inputValue: string) => Array> + maxHeight: { + type: Number }, - searchFn: { - type: Function as PropType<(term: string) => Array>, + filterOption: { + type: [Function, Boolean] as PropType< + boolean | ((input: string, option: OptionObjectItem) => boolean) + > }, loadMore: { - type: Function as PropType<() => Array> + type: Function as PropType<(val: string) => void> } -} as const +} as const; -export const selectDropdownProps = { - options: { - type: Array as PropType, - default: () => [] - } -} as const -export type EditableSelectProps = ExtractPropTypes -export type SelectDropdownProps = ExtractPropTypes \ No newline at end of file +export type EditableSelectProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/editable-select/src/editable-select.scss b/packages/devui-vue/devui/editable-select/src/editable-select.scss index ae3919734b..ae067b20c3 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select.scss +++ b/packages/devui-vue/devui/editable-select/src/editable-select.scss @@ -1,61 +1,37 @@ @import '../../style/theme/color'; @import '../../style/core/animation'; -.devui-editable-select { - .devui-form-group { - input::-ms-clear { - display: none; - } - - ul.devui-list-unstyled { - margin: 0; - overflow-y: auto; - padding: 0; - } - - .devui-dropdown-bg { - background: $devui-list-item-hover-bg; - } - - .devui-popup-tips { - color: $devui-text-weak; - padding: 4px 12px; - } - .devui-form-control { - outline: none; - padding-right: 24px; - } +.devui-editable-select { + .devui-select-chevron-icon { + display: inline-flex; + vertical-align: middle; + transition: transform $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; } - .devui-select-open { - .devui-select-chevron-icon { - transform: rotate(180deg); - - svg path { - fill: $devui-text-weak; - } - } + input::-ms-clear { + display: none; } - .devui-form-control-feedback { - .devui-select-chevron-icon { - display: inline-flex; - vertical-align: middle; - transition: transform $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; - } + .devui-no-data-tip { + user-select: none; + cursor: not-allowed; } - .devui-has-feedback > .devui-form-control-feedback { - line-height: 26px; + .devui-form-control { + outline: none; + padding-right: 24px; } - .devui-dropdown-bg.devui-dropdown-bg { - background-color: inherit; - } - // 下拉部分 .devui-dropdown-menu { width: 100%; display: block; + // TODO: 全局样式被污染,暂时只能这么写 + top: auto !important; + left: auto !important; + } + + .devui-dropdown-menu-cdk { + position: static; } .devui-dropdown-item { @@ -101,7 +77,8 @@ background-color: $devui-unavailable; } } - // 选项disabled + + /* 选项disabled */ .devui-dropdown-item.disabled, .devui-dropdown-item.disabled:hover { cursor: not-allowed; @@ -124,11 +101,32 @@ } } -.devui-dropdown { +.devui-editable-select.devui-select-open { + .devui-select-chevron-icon { + transform: rotate(180deg); + } + .devui-select-chevron-icon svg path { + fill: $devui-text-weak; + } +} +.devui-editable-select.devui-form-group.devui-has-feedback > .devui-form-control-feedback { + line-height: 26px; +} +.devui-editable-select-dropdown { .devui-dropdown-menu { width: 100%; display: block; + width: 100%; + display: block; + // TODO: 全局样式被污染,暂时只能这么写 + top: auto !important; + left: auto !important; + } + + .devui-dropdown-menu-cdk { + position: static; } + .devui-dropdown-item { cursor: pointer; display: block; @@ -172,7 +170,8 @@ background-color: $devui-unavailable; } } - // 选项disabled + + /* 选项disabled */ .devui-dropdown-item.disabled, .devui-dropdown-item.disabled:hover { cursor: not-allowed; @@ -190,7 +189,7 @@ } .devui-popup-tips { - color: $devui-text-weak; + color: $devui-text-weak; // TODO: Color-Question padding: 4px 12px; } } diff --git a/packages/devui-vue/devui/editable-select/src/editable-select.tsx b/packages/devui-vue/devui/editable-select/src/editable-select.tsx index b137d25b9c..b9dec3c05e 100644 --- a/packages/devui-vue/devui/editable-select/src/editable-select.tsx +++ b/packages/devui-vue/devui/editable-select/src/editable-select.tsx @@ -1,238 +1,232 @@ import { defineComponent, - Transition, - ref, + withModifiers, computed, + ref, + Transition, + SetupContext, reactive, - toRefs, - provide, - renderSlot -} from 'vue' -import { - OptionItem, - editableSelectProps, - EditableSelectProps, - ConnectionPosition -} from './editable-select-types' -import SelectDropdown from './components/dropdown' -import './editable-select.scss' -import ClickOutside from '../../shared/devui-directive/clickoutside' -import { debounce } from 'lodash' -import { className } from './utils' -import keyboardSelect from './composable/use-keyboard-select' + watch +} from 'vue'; +import { editableSelectProps, EditableSelectProps } from './editable-select-types'; +import clickOutside from '../../shared/devui-directive/clickoutside'; +import { className } from '../src/utils/index'; +import './editable-select.scss'; +import { OptionObjectItem } from './editable-select-type'; +import { userFilterOptions } from './composables/use-filter-options'; +import { useInput } from './composables/use-input'; +import { useLazyLoad } from './composables/use-lazy-load'; +import { useKeyboardSelect } from './composables/use-keyboard-select'; export default defineComponent({ name: 'DEditableSelect', - directives: { ClickOutside }, + directives: { + clickOutside + }, props: editableSelectProps, - emits: ['update:modelValue'], - setup(props: EditableSelectProps, ctx) { - const renderDropdown = (condition: boolean, type: number) => { - if (!condition && type === 0) { - return ( - - - - ) - } else if (condition && type === 1) { + emits: ['update:modelValue', 'search', 'loadMore'], + setup(props: EditableSelectProps, ctx: SetupContext) { + const getItemCls = (option: OptionObjectItem, index: number) => { + const { optionDisabledKey: disabledKey } = props; + return className('devui-dropdown-item', { + disabled: disabledKey ? !!option[disabledKey] : false, + selected: index === selectIndex.value, + 'devui-dropdown-bg': index === hoverIndex.value + }); + }; + // 渲染下拉列表,根据appendToBody属性判断是否渲染在body下 + const renderDropdown = () => { + if (props.appendToBody) { return (
    - +
    +
      + {filteredOptions.value.map((option, index) => { + return ( +
    • { + e.stopPropagation(); + handleClick(option); + }} + > + {ctx.slots.itemTemplate ? ctx.slots.itemTemplate(option) : option.label} +
    • + ); + })} +
    • +
      {emptyText.value}
      +
    • +
    +
    - ) + ); + } else { + return ( + +
    +
      + {filteredOptions.value.map((option, index) => { + return ( +
    • { + e.stopPropagation(); + handleClick(option); + }} + > + {ctx.slots.itemTemplate ? ctx.slots.itemTemplate(option) : option.label} +
    • + ); + })} +
    • +
      {emptyText.value}
      +
    • +
    +
    +
    + ); } - } - - const renderDefaultSlots = (item) => { - return ctx.slots.default ? renderSlot(ctx.slots, 'default', { item }) : item.name - } + }; + //Ref + const dopdownRef = ref(); + const origin = ref(); - const renderEmptySlots = () => { - return ctx.slots.empty ? renderSlot(ctx.slots, 'empty') : emptyText.value - } - - const origin = ref() - const dropdownRef = ref() - const visible = ref(false) - const inputValue = ref('') - const selectedIndex = ref(0) - const hoverIndex = ref(0) - const query = ref(props.modelValue) - const position = reactive({ + const position = reactive({ originX: 'left', originY: 'bottom', overlayX: 'left', overlayY: 'top' - }) - const wait = computed(() => (props.remote ? 300 : 0)) - - const emptyText = computed(() => { - const options = filteredOptions.value - if (!props.remote && options.length === 0) { - return '没有相关记录' - } - if (options.length === 0) { - return '没有数据' - } - return null - }) + }); + const visible = ref(false); + const inputValue = ref(props.modelValue); + const hoverIndex = ref(0); + const selectIndex = ref(0); + //标准化options,统一处理成[{}]的形式 const normalizeOptions = computed(() => { - let options: OptionItem - const { disabledKey } = props - disabledKey ? disabledKey : 'disabled' - return props.options.map((item) => { - if (typeof item !== 'object') { - options = { - name: item - } - return options + return props.options.map((option) => { + if (typeof option === 'object') { + return { + label: option.label ? option.label : option.value, + value: option.value, + ...option + }; } - return item - }) - }) + return { + label: option + '', + value: option + }; + }); + }); + //非远程搜索的情况下对数组进行过滤 + const filteredOptions = userFilterOptions(normalizeOptions, inputValue, props.filterOption); - const filteredOptions = computed(() => { - const isValidOption = (o: OptionItem) => { - const query = inputValue.value - const containsQueryString = query - ? o.name.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) >= 0 - : true - return containsQueryString + const emptyText = computed(() => { + let text: string; + if (props.filterOption !== false && !filteredOptions.value.length) { + text = '找不到相关记录'; + } else if (props.filterOption === false && !filteredOptions.value.length) { + text = '没有数据'; } - return normalizeOptions.value - .map((item) => { - if (props.remote || isValidOption(item)) { - return item - } - return null - }) - .filter((item) => item !== null) - }) - const findIndex = (o: OptionItem) => { - return normalizeOptions.value.findIndex((item) => { - return item.name === o.name - }) - } - - const handleClose = () => { - visible.value = false - } + return ctx.slots.noResultItemTemplate ? ctx.slots.noResultItemTemplate() : text; + }); + //下拉列表显影切换 const toggleMenu = () => { - if (!props.disabled) { - visible.value = !visible.value - } - } - - const onInputChange = (val: string) => { - if (props.filterMethod) { - props.filterMethod(val) - } else if (props.remote) { - props.remoteMethod(val) - } - } + visible.value = !visible.value; + }; - const debouncedOnInputChange = debounce(onInputChange, wait.value) + const closeMenu = () => { + visible.value = false; + }; + // 懒加载 + const { loadMore } = useLazyLoad(dopdownRef, inputValue, props.filterOption, props.loadMore); - const handleInput = (event) => { - const value = event.target.value - inputValue.value = value - query.value = value - if (props.remote) { - debouncedOnInputChange(value) - } else { - onInputChange(value) - } - } + //输入框变化后的逻辑 + const { handleInput } = useInput(inputValue, ctx); - const selectOptionClick = (e, item: OptionItem) => { - const { disabledKey } = props - if (disabledKey && item[disabledKey]) { - e.stopPropagation() - } else { - query.value = item.name - selectedIndex.value = findIndex(item) - inputValue.value = '' - ctx.emit('update:modelValue', item.name) - visible.value = false - } - } - - const loadMore = () => { - if (!props.enableLazyLoad) return - const dropdownVal = dropdownRef.value - if (dropdownVal.clientHeight + dropdownVal.scrollTop >= dropdownVal.scrollHeight) { - props.loadMore() - } - } - const { handleKeydown } = keyboardSelect( - dropdownRef, + const handleClick = (option: OptionObjectItem) => { + const { optionDisabledKey: disabledKey } = props; + if (disabledKey && !!option[disabledKey]) return; + ctx.emit('update:modelValue', option.label); + closeMenu(); + }; + // 键盘选择 + const { handleKeydown } = useKeyboardSelect( + dopdownRef, + props.optionDisabledKey, visible, hoverIndex, - selectedIndex, + selectIndex, filteredOptions, toggleMenu, - selectOptionClick - ) + closeMenu, + handleClick + ); - provide('InjectionKey', { - dropdownRef, - props: reactive({ - ...toRefs(props) - }), - visible, - emptyText, - selectedIndex, - hoverIndex, - loadMore, - selectOptionClick, - renderDefaultSlots, - renderEmptySlots - }) + watch( + () => props.modelValue, + (newVal) => { + inputValue.value = newVal; + } + ); return () => { const selectCls = className('devui-editable-select devui-form-group devui-has-feedback', { - 'devui-select-open': visible.value - }) - const inputCls = className( - 'devui-form-control devui-dropdown-origin devui-dropdown-origin-open', - { - disabled: props.disabled - } - ) - + 'devui-select-open': visible.value === true + }); return ( - <> -
    - - + {renderDropdown()} +
    + ); + }; } -}) +}); diff --git a/packages/devui-vue/devui/editable-select/src/utils/index.ts b/packages/devui-vue/devui/editable-select/src/utils/index.ts index 91a72c870f..8b81777a72 100644 --- a/packages/devui-vue/devui/editable-select/src/utils/index.ts +++ b/packages/devui-vue/devui/editable-select/src/utils/index.ts @@ -1,31 +1,19 @@ -import { VNode } from 'vue'; - /** * 动态获取class字符串 * @param classStr 是一个字符串,固定的class名 * @param classOpt 是一个对象,key表示class名,value为布尔值,true则添加,否则不添加 * @returns 最终的class字符串 */ -export function className( + export function className( classStr: string, classOpt?: { [key: string]: boolean; } -): string { + ): string { let classname = classStr; if (typeof classOpt === 'object') { - Object.keys(classOpt).forEach((key) => { - classOpt[key] && (classname += ` ${key}`); - }); + Object.keys(classOpt).forEach((key) => { + classOpt[key] && (classname += ` ${key}`); + }); } - + return classname; -} -/** - * - * @param condition 渲染条件 - * @param node1 待渲染的组件 - * @param node2 - * @returns 最终被渲染的组件 - */ -export function renderCondition(condition: unknown, node1: VNode, node2?: VNode): VNode { - return !!condition ? node1 : node2; -} \ No newline at end of file + } \ No newline at end of file diff --git a/packages/devui-vue/devui/form/src/directive/d-validate-rules.ts b/packages/devui-vue/devui/form/src/directive/d-validate-rules.ts index 0fd265013f..89aa54e664 100644 --- a/packages/devui-vue/devui/form/src/directive/d-validate-rules.ts +++ b/packages/devui-vue/devui/form/src/directive/d-validate-rules.ts @@ -1,6 +1,6 @@ import AsyncValidator, { RuleItem } from 'async-validator'; import { VNode, DirectiveBinding } from 'vue'; -import { debounce } from 'lodash-es'; +import { debounce } from 'lodash'; import { EventBus, isObject, hasKey } from '../util'; import './style.scss'; @@ -14,6 +14,7 @@ interface ValidateFnParam { messageShowType: MessageShowType dfcUID: string popPosition: PopPosition | Array + updateOn?: UpdateOn } interface CustomValidatorRuleObject { @@ -46,6 +47,7 @@ export interface ShowPopoverErrorMessageEventData { message?: string uid?: string, popPosition?: PopPosition + [prop : string]: any } type MessageShowType = 'popover' | 'text' | 'none' | 'toast'; @@ -199,18 +201,28 @@ function getKeyValueOfObjectList(obj): {key: string; value: any;}[] { function handleErrorStrategy(el: HTMLElement): void { const classList: Array = [...el.classList]; - classList.push('d-validate-rules-error-pristine'); + classList.push('devui-validate-rules-error-pristine'); el.setAttribute('class', classList.join(' ')); } function handleErrorStrategyPass(el: HTMLElement): void { const classList: Array = [...el.classList]; - const index = classList.indexOf('d-validate-rules-error-pristine'); + const index = classList.indexOf('devui-validate-rules-error-pristine'); index !== -1 && classList.splice(index, 1); el.setAttribute('class', classList.join(' ')); } -function handleValidateError({el, tipEl, message, isFormTag, messageShowType, dfcUID, popPosition = 'right-bottom'}: Partial): void { +function getFormControlUID(el: HTMLElement): string { + if(el.tagName.toLocaleLowerCase() === 'body') return ''; + let uid = '' + if(el.parentElement.id.startsWith('dfc-')) { + return el.parentElement.id; + }else { + uid = getFormControlUID(el.parentElement); + } +} + +function handleValidateError({el, tipEl, message = '', isFormTag, messageShowType, dfcUID, popPosition = 'right-bottom', updateOn}: Partial): void { // 如果该指令用在form标签上,这里做特殊处理 if(isFormTag && messageShowType === MessageShowTypeEnum.toast) { // todo:待替换为toast @@ -218,15 +230,19 @@ function handleValidateError({el, tipEl, message, isFormTag, messageShowType, df return; } + if(!dfcUID) { + dfcUID = getFormControlUID(el); + } + // messageShowType为popover时,设置popover if(MessageShowTypeEnum.popover === messageShowType) { - EventBus.emit("showPopoverErrorMessage", {showPopover: true, message, uid: dfcUID, popPosition} as ShowPopoverErrorMessageEventData); + EventBus.emit('showPopoverErrorMessage', {showPopover: true, message, uid: dfcUID, popPosition, updateOn} as ShowPopoverErrorMessageEventData); return; } tipEl.innerText = '' + message; tipEl.style.display = 'inline-flex'; - tipEl.setAttribute('class', 'd-validate-tip'); + tipEl.setAttribute('class', 'devui-validate-tip'); handleErrorStrategy(el); } @@ -250,7 +266,7 @@ function getFormName(binding: DirectiveBinding): string { } // 校验处理函数 -function validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowType, dfcUID, popPosition}: Partial) { +function validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowType, dfcUID, popPosition, updateOn}: Partial) { validator.validate({modelName: modelValue}).then(() => { handleValidatePass(el, tipEl); }).catch((err) => { @@ -265,7 +281,7 @@ function validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowTyp msg = errors[0].message; } - handleValidateError({el, tipEl, message: msg, isFormTag, messageShowType, dfcUID, popPosition}); + handleValidateError({el, tipEl, message: msg, isFormTag, messageShowType, dfcUID, popPosition, updateOn}); }) } @@ -295,7 +311,7 @@ export default { if(refName) { // 判断d-form是否传递了messageShowType属性 - messageShowType = binding.instance[refName]["messageShowType"] ?? "popover"; + messageShowType = binding.instance[refName]['messageShowType'] ?? 'popover'; } // errorStrategy可配置在options对象中 @@ -394,14 +410,21 @@ export default { const htmlEventValidateHandler = (e) => { const modelValue = e.target.value; if(messageShowType === MessageShowTypeEnum.popover) { - EventBus.emit("showPopoverErrorMessage", {showPopover: false, message: "", uid: dfcUID, popPosition} as ShowPopoverErrorMessageEventData); + EventBus.emit('showPopoverErrorMessage', {showPopover: false, message: '', uid: dfcUID, popPosition, updateOn} as ShowPopoverErrorMessageEventData); } - validateFn({validator, modelValue, el, tipEl, isFormTag: false, messageShowType, dfcUID, popPosition}); + validateFn({validator, modelValue, el, tipEl, isFormTag: false, messageShowType, dfcUID, popPosition, updateOn}); } // 监听事件验证 vnode.children[0].el.addEventListener(updateOn, htmlEventValidateHandler); + // 如果校验时机为change,则在focus时关闭popover + if(messageShowType === MessageShowTypeEnum.popover && updateOn === UpdateOnEnum.change) { + vnode.children[0].el.addEventListener('focus', () => { + EventBus.emit('showPopoverErrorMessage', {showPopover: false, uid: dfcUID, updateOn} as ShowPopoverErrorMessageEventData); + }); + } + // 设置errorStrategy if(errorStrategy === ErrorStrategyEnum.pristine) { handleErrorStrategy(el); @@ -415,7 +438,7 @@ export default { const modelValue = isFormTag ? '' : vnode.children[0].el.value; // 进行提交验证 - validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowType}); + validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowType, updateOn: 'submit'}); }); } diff --git a/packages/devui-vue/devui/form/src/directive/d-validate.ts b/packages/devui-vue/devui/form/src/directive/d-validate.ts new file mode 100644 index 0000000000..76d5e03e96 --- /dev/null +++ b/packages/devui-vue/devui/form/src/directive/d-validate.ts @@ -0,0 +1,259 @@ +import { VNode, DirectiveBinding, h, render, nextTick } from 'vue'; +import { debounce } from 'lodash-es'; +import { EventBus, transformCamelToDash } from '../util'; +import useValidate from '../use-validate'; +import dPopover from '../../../popover/src/popover'; +import {DFormValidateSubmitData, positionType} from '../form-types'; +import './style.scss'; + +interface BindingValueRules { + [prop:string]: unknown +} + +interface BindingValue { + prop: string + modelName?: string + rules: BindingValueRules + validators?: any + asyncValidators?: any + errorStrategy?: 'pristine' | 'dirty' + updateOn: 'change' | 'input' | 'submit' + asyncDebounceTime?: number | string + messageShowType?: 'popover' | 'text' | 'none' + popPosition: string | string[] + messageChange?: (msg, { errors, fields }) => {} + [prop: string]: any +} + +const getTargetElement = (el: HTMLElement, targetTag: string) => { + if (!el) return; + let tempEl:HTMLElement = el; + while(tempEl?.tagName && tempEl.tagName.toLocaleLowerCase() !== 'body') { + if(tempEl.tagName.toLocaleLowerCase() === targetTag) { + return tempEl; + } + tempEl = tempEl.parentElement; + } +} + +export default { + mounted(el: HTMLElement, binding: DirectiveBinding): void { + let { prop, rules, validators, asyncValidators, errorStrategy, updateOn = 'input', asyncDebounceTime = 300, messageShowType = 'popover', messageChange, popPosition = ['right', 'bottom'] }: BindingValue = binding.value; + const {instance, arg: modelName} = binding; + + const instanceRef = instance[Object.keys(instance.$refs)[0]]; + if(instanceRef && instanceRef?.messageShowType) { + messageShowType = instanceRef.messageShowType; + } + const hasModelName = !!modelName; + + const objToStyleString = (obj: any = {}) => { + let style = ''; + for (const key in obj) { + style += `${transformCamelToDash(key)}: ${obj[key]};` + } + return style; + } + + const renderPopover = (msg, visible = true) => { + if(messageShowType !== 'popover') return; + el.style.position = 'relative'; + const popoverPosition = () => { + return Array.isArray(popPosition) ? popPosition.join('-') : popPosition; + } + + const popover = h(dPopover, { + visible: visible, + controlled: updateOn !== 'change', + content: msg, + popType: 'error', + position: popoverPosition() as positionType, + }); + + // 这里使用比较hack的方法控制popover显隐,因为点击popover外部元素隐藏popover之后,再重新传入visible不起作用了,popover不会重新渲染了 + nextTick(() => { + if(visible) { + addElClass(popover.el as HTMLElement, 'devui-popover-isVisible') + }else { + removeElClass(popover.el as HTMLElement, 'devui-popover-isVisible') + } + }) + + const popoverWrapperStyle = () => { + let rect = el.getBoundingClientRect(); + let style: any = { + position: 'absolute', + height: 0, + top: (rect.height / 2) + 'px', + right: 0, + } + + let p = popoverPosition(); + if(popPosition === 'bottom' || popPosition === 'top') { + style.left = '50%'; + } + if(popPosition === 'left' || popPosition === 'right') { + style.top = 0; + } + if(p.includes('top')) { + style.top = -(rect.height / 2) + 'px'; + } + if(p.endsWith('-bottom')) { + style.top = (rect.height / 2) + 'px'; + } + if(p.includes('left')) { + style.left = 0; + } + if(p.includes('right')) { + delete style.left; + style.right = 0; + } + + if(p.startsWith('bottom')) { + delete style.top; + style.bottom = 0; + } + if(p.startsWith('top')) { + delete style.bottom; + } + + return objToStyleString(style); + }; + + const vn = h('div', { + style: popoverWrapperStyle() + }, popover) + render(vn, el); + } + + const tipEl = document.createElement('div'); + if(messageShowType === 'text') { + el.parentNode.appendChild(tipEl); + } + + const renderTipEl = (msg, visible = true) => { + tipEl.innerText = msg; + tipEl.style.display = visible ? 'block' : 'none'; + tipEl.setAttribute('class', 'devui-validate-tip'); + } + + const addElClass = (el: HTMLElement, className: string) => { + let currentClasses = el.getAttribute('class'); + if(!currentClasses.includes(className)) { + currentClasses = currentClasses.trim() + (currentClasses.trim() ? ' ' : '') + className; + } + el.setAttribute('class', currentClasses); + } + + const removeElClass = (el: HTMLElement, className: string) => { + let currentClasses = el.getAttribute('class'); + currentClasses = currentClasses.replace(className, ''); + el.setAttribute('class', currentClasses); + } + + const {validate, createDevUIBuiltinValidator} = useValidate(); + let propRule = {} || [] as any; // 值为对象数组或单个对象 + + const isCustomValidator = validators !== undefined || asyncValidators !== undefined; + if(isCustomValidator) { + validators && (rules = validators); + asyncValidators && (rules = asyncValidators); + if(asyncValidators) { + let time = Number(asyncDebounceTime); + if(isNaN(time)) { + console.warn('[v-d-validate] invalid asyncDebounceTime'); + time = 300; + } + rules = asyncValidators.map(item => { + let res = { + message: item.message, + asyncValidator: (rule, value) => { + return new Promise(debounce((resolve, reject) => { + const res = item.asyncValidator(rule, value); + if(res) { + resolve(''); + }else { + reject(rule.message); + } + }, time)) + }, + } as any; + return res; + }) + } + }else { + if(Array.isArray(rules)) { + rules.map(item => { + return createDevUIBuiltinValidator(item); + }); + }else { + rules = createDevUIBuiltinValidator(rules); + } + } + + let descriptor: any = { + [prop]: rules + } + const validateFn = async () => { + const validateModel = { + [prop]: hasModelName ? instance[modelName][prop] : instance[prop] + }; + return validate(descriptor, validateModel).then(res => { + renderPopover('', false); + removeElClass(el, 'devui-error'); + messageShowType === 'text' && renderTipEl('', true); + return res; + }).catch(({ errors, fields }) => { + let msg = propRule.message ?? fields[prop][0].message; + renderPopover(msg); + addElClass(el, 'devui-error'); + messageShowType === 'text' && renderTipEl(msg, true); + if(messageChange && typeof messageChange === 'function') { + messageChange(msg, { errors, fields }); + } + return { errors, fields }; + }) + } + + if(errorStrategy === 'pristine') { + validateFn(); + }else { + el.childNodes[0].addEventListener(updateOn, () => { + validateFn(); + }) + if(updateOn === 'change') { + el.childNodes[0].addEventListener('focus', () => { + renderPopover('', false); + }) + } + } + + // 处理表单提交校验 + const formTag = getTargetElement(el, 'form') as HTMLFormElement; + if(formTag && updateOn === 'submit') { + const formName = formTag.name; + const formSubmitDataCallback: any = (val: DFormValidateSubmitData) => { + validateFn().then((res: any) => { + val.callback(!!!res?.errors, { errors: res?.errors, fields: res?.fields }); + }).catch(({errors, fields}) => { + console.log('validateFn {errors, fields}', {errors, fields}); + }); + }; + EventBus.on(`formSubmit:${formName}`, formSubmitDataCallback); + EventBus.on(`formReset:${formName}:${prop}`, () => { + renderPopover('', false); + removeElClass(el, 'devui-error'); + messageShowType === 'text' && renderTipEl('', false); + }); + } + }, + + beforeUnmount(el: HTMLElement, binding: DirectiveBinding) { + const {prop} = binding.value; + const formTag = getTargetElement(el, 'form') as HTMLFormElement; + const formName = formTag.name; + + EventBus.off(`formSubmit:${formName}`); + EventBus.off(`formReset:${formName}:${prop}`); + } +} diff --git a/packages/devui-vue/devui/form/src/directive/style.scss b/packages/devui-vue/devui/form/src/directive/style.scss index 56345b9d66..11a724fb1e 100644 --- a/packages/devui-vue/devui/form/src/directive/style.scss +++ b/packages/devui-vue/devui/form/src/directive/style.scss @@ -1,5 +1,5 @@ -.d-validate-rules-error-pristine { +.devui-validate-rules-error-pristine { // background-color: #ffeeed; input { background-color: #ffeeed; @@ -15,10 +15,17 @@ } } -.d-validate-tip { +.devui-validate-tip { display: flex; justify-content: center; align-items: center; font-size: 12px; color: #f66f6a; } + +.devui-error { + input, .devui-tags { + border-color: var(--devui-danger-line,#f66f6a) !important; + background-color: var(--devui-danger-bg,#ffeeed) !important; + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/form/src/form-control/form-control.scss b/packages/devui-vue/devui/form/src/form-control/form-control.scss index 1c608a95e5..7e246ad1c8 100644 --- a/packages/devui-vue/devui/form/src/form-control/form-control.scss +++ b/packages/devui-vue/devui/form/src/form-control/form-control.scss @@ -1,14 +1,15 @@ -.form-control { +.devui-form-control { position: relative; + width: 100%; - .star { + .devui-star { color: red; } .devui-form-control-container { position: relative; - .feedback-status { + .devui-feedback-status { position: absolute; top: 50%; right: 0; @@ -31,6 +32,7 @@ .devui-form-control-container-horizontal { display: flex; + width: 100%; .devui-radio { &:not(:last-child) { @@ -50,43 +52,16 @@ } } - input, - .devui-tags-host { - width: 200px; - } - - .d-validate-tip { - margin: 0 10px; + .devui-validate-tip { + margin: 0; } } - .devui-control-content-wrapper { - .devui-popover-wrapper { - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; - z-index: 9; - - & > div { - width: inherit; - height: 100%; - } - } + width: 100%; } - .with-popover { - position: relative; - & > div { - z-index: 10; - } - } - - - - .has-feedback { + .devui-has-feedback { display: flex; align-items: center; @@ -95,7 +70,7 @@ } } - .feedback-error { + .devui-feedback-error { border: 1px solid #f66f6a; border-radius: 2px; diff --git a/packages/devui-vue/devui/form/src/form-control/form-control.tsx b/packages/devui-vue/devui/form/src/form-control/form-control.tsx index 55612f3a6f..90d6397b9c 100644 --- a/packages/devui-vue/devui/form/src/form-control/form-control.tsx +++ b/packages/devui-vue/devui/form/src/form-control/form-control.tsx @@ -1,7 +1,8 @@ import { defineComponent, inject, ref, computed, reactive, onMounted, Teleport } from 'vue'; -import { uniqueId } from 'lodash-es'; +import { uniqueId } from 'lodash'; import { IForm, formControlProps, formInjectionKey } from '../form-types'; import { ShowPopoverErrorMessageEventData } from '../directive/d-validate-rules' +import clickoutsideDirective from '../../../shared/devui-directive/clickoutside' import { EventBus, getElOffset } from '../util'; import Icon from '../../../icon/src/icon'; import Popover from '../../../popover/src/popover'; @@ -11,16 +12,20 @@ type positionType = 'top' | 'right' | 'bottom' | 'left'; export default defineComponent({ name: 'DFormControl', + directives: { + clickoutside: clickoutsideDirective + }, props: formControlProps, setup(props, ctx) { const formControl = ref(); const dForm = reactive(inject(formInjectionKey, {} as IForm)); const labelData = reactive(dForm.labelData); const isHorizontal = labelData.layout === 'horizontal'; - const uid = uniqueId("dfc-"); + const uid = uniqueId('dfc-'); const showPopover = ref(false); - const tipMessage = ref(""); - const popPosition = ref("bottom"); + const updateOn = ref('change'); + const tipMessage = ref(''); + const popPosition = ref('bottom'); let rectInfo: Partial = { width: 0, height: 0 @@ -35,14 +40,15 @@ export default defineComponent({ onMounted(() => { const el = document.getElementById(uid); elOffset = getElOffset(el); - EventBus.on("showPopoverErrorMessage", (data: ShowPopoverErrorMessageEventData) => { + EventBus.on('showPopoverErrorMessage', (data: ShowPopoverErrorMessageEventData) => { if (uid === data.uid) { rectInfo = el.getBoundingClientRect(); - popoverLeftPosition = popPosition.value === "top" || popPosition.value === "bottom" ? rectInfo.right - (rectInfo.width / 2) : rectInfo.right; - popoverTopPosition = popPosition.value === "top" ? elOffset.top + (rectInfo.height / 2) - rectInfo.height : elOffset.top + (rectInfo.height / 2); showPopover.value = data.showPopover; tipMessage.value = data.message; popPosition.value = data.popPosition as any; // todo: 待popover组件positionType完善类型之后再替换类型 + popoverLeftPosition = popPosition.value === 'top' || popPosition.value === 'bottom' ? rectInfo.right - (rectInfo.width / 2) : rectInfo.right; + popoverTopPosition = popPosition.value === 'top' ? elOffset.top + (rectInfo.height / 2) - rectInfo.height : elOffset.top + (rectInfo.height / 2); + updateOn.value = data.updateOn ?? 'change'; } }); }); @@ -60,13 +66,20 @@ export default defineComponent({ } }) + const handleClickOutside = () => { + if(updateOn.value !== 'change') { + showPopover.value = false; + } + } + return () => { const { feedbackStatus, extraInfo, } = props; - return
    - + return
    + { showPopover.value && +
    - +
    -
    + } +
    {ctx.slots.default?.()}
    { (feedbackStatus || ctx.slots.suffixTemplate?.()) && -
{slots.default()} - ) - } - } -}) + ); + }; + }, +}); diff --git a/packages/devui-vue/devui/tag/__tests__/tag.spec.ts b/packages/devui-vue/devui/tag/__tests__/tag.spec.ts index 9db389e4d2..c46756f57c 100644 --- a/packages/devui-vue/devui/tag/__tests__/tag.spec.ts +++ b/packages/devui-vue/devui/tag/__tests__/tag.spec.ts @@ -2,7 +2,116 @@ import { mount } from '@vue/test-utils' import { Tag } from '../index' describe('tag test', () => { - it('tag init render', async () => { - // todo + it('init render', async () => { + const wrapper = mount(Tag) + expect(wrapper.find('.devui-tag').exists()).toBeTruthy() + }) + it('props type', () => { + const wrapper = mount(Tag, { + propsData: { + type: 'primary' + } + }) + expect(wrapper.find('.devui-tag span').classes()).toContain('devui-tag-primary') + }) + it('props color', () => { + const wrapper = mount(Tag, { + propsData: { + color: 'red-w98' //#f66f6a rgb(246, 111, 106) + } + }) + expect(wrapper.find('.devui-tag span').attributes('style')).toContain('rgb(246, 111, 106)') + }) + it('props color custom', () => { + const wrapper = mount(Tag, { + propsData: { + color: '#aa2116' //rgb(170, 33, 22) + } + }) + expect(wrapper.find('.devui-tag span').attributes('style')).toContain('rgb(170, 33, 22)') + }) + it('props titleContent', () => { + const titleContent = 'tagTitle test' + const wrapper = mount(Tag, { + props: { titleContent } + }) + expect(wrapper.get('.devui-tag span').attributes('title')).toBe(titleContent) + }) + it('props deletable show', async () => { + const wrapper = mount(Tag, { + propsData: { + deletable: false + } + }) + expect(wrapper.find('.remove-button').exists()).toBeFalsy() + await wrapper.setProps({ deletable: true }) + expect(wrapper.find('.remove-button').exists()).toBeTruthy() + }) + it('props deletable hide', async () => { + const wrapper = mount(Tag, { + propsData: { + deletable: true + } + }) + const btn = wrapper.find('.remove-button') + expect(btn.exists()).toBeTruthy() + await btn.trigger('click') + expect(wrapper.find('.devui-tag').exists()).toBeFalsy() + }) + it('event tagDelete', async () => { + const wrapper = mount(Tag, { + propsData: { + deletable: true + } + }) + await wrapper.find('.remove-button').trigger('click') + expect(wrapper.emitted('tagDelete').length).toBeGreaterThan(0) + }) + + it('props checked', async () => { + const wrapper = mount(Tag, { + propsData: { + type: 'primary' //对应颜色:rgb(94, 124, 224) + } + }) + expect(wrapper.find('.devui-tag span').attributes('style')).toContain( + 'color: rgb(94, 124, 224);' + ) + await wrapper.setProps({ checked: true }) + expect(wrapper.find('.devui-tag span').attributes('style')).toContain( + 'background-color: rgb(94, 124, 224);' + ) + expect(wrapper.emitted('checkedChange').length).toBeGreaterThan(0) + }) + it('event checkedChange', async () => { + const wrapper = mount(Tag) + await wrapper.setProps({ checked: true }) + expect(wrapper.emitted('checkedChange').length).toBeGreaterThan(0) + expect(wrapper.emitted('checkedChange')[0]).toEqual([true]) + await wrapper.setProps({ checked: false }) + expect(wrapper.emitted('checkedChange').length).toBeGreaterThan(1) + expect(wrapper.emitted('checkedChange')[1]).toEqual([false]) + }) + + it('event click', async () => { + const wrapper = mount(Tag) + await wrapper.find('.devui-tag').trigger('click') + expect(wrapper.emitted('click').length).toBeGreaterThan(0) + }) + it('slot default string', async () => { + const wrapper = mount(Tag, { + slots: { + default: 'default slot test' + } + }) + expect(wrapper.text()).toContain('default slot test') + }) + it('slot default component', async () => { + const wrapper = mount(Tag, { + slots: { + default: ['', 'icon component test'] + } + }) + expect(wrapper.find('i').classes()).toContain('icon-like') }) }) diff --git a/packages/devui-vue/devui/tag/src/hooks/use-class.ts b/packages/devui-vue/devui/tag/src/hooks/use-class.ts index 0a5015a92a..e5afcc476a 100644 --- a/packages/devui-vue/devui/tag/src/hooks/use-class.ts +++ b/packages/devui-vue/devui/tag/src/hooks/use-class.ts @@ -3,7 +3,9 @@ import { tagProps, TagProps } from '../tag-types' export default function (props: TagProps) { return computed(() => { - const { type, color } = props - return `devui-tag devui-tag-${type || (color ? 'colorful' : '') || 'default'}` + const { type, color, deletable } = props + return `devui-tag devui-tag-${type || (color ? 'colorful' : '') || 'default'} ${ + deletable ? 'devui-tag-deletable' : '' + }` }) } diff --git a/packages/devui-vue/devui/tag/src/tag.scss b/packages/devui-vue/devui/tag/src/tag.scss index 0ef0d58515..a7bfc3e709 100644 --- a/packages/devui-vue/devui/tag/src/tag.scss +++ b/packages/devui-vue/devui/tag/src/tag.scss @@ -27,7 +27,10 @@ $devui-tag-normal-config: ( border-color: $devui-danger-line ), colorful: ( - background-color: #fff + background-color: #ffffff + ), + deletable: ( + padding-right: 32px ) ); @@ -47,8 +50,7 @@ $devui-tag-normal-config: ( align-items: center; position: relative; cursor: default; - - @each $type in default, primary, success, warning, danger, colorful { + @each $type in default, primary, success, warning, danger, colorful, deletable { &.devui-tag-#{$type} { @each $key, $value in map-get($devui-tag-normal-config, $type) { #{$key}: $value; @@ -56,6 +58,7 @@ $devui-tag-normal-config: ( } } } + .remove-button { display: inline-block; margin-left: 12px; @@ -66,8 +69,13 @@ $devui-tag-normal-config: ( line-height: 12px; border-radius: 50%; text-align: center; - position: relative; - top: -0.22em; - vertical-align: middle; + position: absolute; + top: 50%; + transform: translateY(-50%); + + i { + /* 取消icon自带的偏移 */ + vertical-align: 0; + } } } diff --git a/packages/devui-vue/devui/textarea/src/textarea.tsx b/packages/devui-vue/devui/textarea/src/textarea.tsx index 52c3f77ae5..e7c6bb88bf 100644 --- a/packages/devui-vue/devui/textarea/src/textarea.tsx +++ b/packages/devui-vue/devui/textarea/src/textarea.tsx @@ -1,11 +1,11 @@ -import { defineComponent, ref } from "vue"; -import { textareaProps, TextareaProps } from "./textarea-types"; -import "./textarea.scss"; +import { defineComponent, ref } from 'vue'; +import { textareaProps, TextareaProps } from './textarea-types'; +import './textarea.scss'; export default defineComponent({ - name: "DTextarea", + name: 'DTextarea', props: textareaProps, - emits: ["update:value", "focus", "blur", "change", "keydown"], + emits: ['update:value', 'focus', 'blur', 'change', 'keydown'], setup(props: TextareaProps, ctx) { const textareaCls = { error: props.error, @@ -16,19 +16,19 @@ export default defineComponent({ const onInput = ($event: Event) => { const inputValue = ($event.target as HTMLInputElement).value; curValueRef.value = inputValue; - ctx.emit("update:value", inputValue); + ctx.emit('update:value', inputValue); }, onFocus = ($event: Event) => { - ctx.emit("focus", $event); + ctx.emit('focus', $event); }, onBlur = ($event: Event) => { - ctx.emit("blur", $event); + ctx.emit('blur', $event); }, onChange = ($event: Event) => { - ctx.emit("change", ($event.target as HTMLInputElement).value); + ctx.emit('change', ($event.target as HTMLInputElement).value); }, onKeydown = ($event: KeyboardEvent) => { - ctx.emit("keydown", $event); + ctx.emit('keydown', $event); }; return { @@ -80,7 +80,7 @@ export default defineComponent({ {showCount && (
{curValueRef.length} - {!(maxLength ?? false) ? "" : " / " + maxLength} + {!(maxLength ?? false) ? '' : ' / ' + maxLength}
)} diff --git a/packages/devui-vue/devui/time-axis/src/time-axis.scss b/packages/devui-vue/devui/time-axis/src/time-axis.scss index 24b1ec55a1..187a20669f 100644 --- a/packages/devui-vue/devui/time-axis/src/time-axis.scss +++ b/packages/devui-vue/devui/time-axis/src/time-axis.scss @@ -185,7 +185,6 @@ $devui-time-axis-item-dot-size: 18px; // } // } //} - &-type { &-primary { border: 2px solid $devui-placeholder; diff --git a/packages/devui-vue/devui/time-picker/src/components/popup-line/index.scss b/packages/devui-vue/devui/time-picker/src/components/popup-line/index.scss index 854f28384f..d6b63d346f 100644 --- a/packages/devui-vue/devui/time-picker/src/components/popup-line/index.scss +++ b/packages/devui-vue/devui/time-picker/src/components/popup-line/index.scss @@ -8,6 +8,7 @@ height: 256px; border-bottom: 1px solid $devui-dividing-line; display: flex; + .time-item { height: 100%; display: inline-block; diff --git a/packages/devui-vue/devui/time-picker/src/components/time-popup/index.scss b/packages/devui-vue/devui/time-picker/src/components/time-popup/index.scss index bc97ca4673..61a8ae49cc 100644 --- a/packages/devui-vue/devui/time-picker/src/components/time-popup/index.scss +++ b/packages/devui-vue/devui/time-picker/src/components/time-popup/index.scss @@ -30,4 +30,4 @@ display: flex; justify-content: space-between; align-items: center; -} \ No newline at end of file +} diff --git a/packages/devui-vue/devui/time-picker/src/components/time-popup/index.tsx b/packages/devui-vue/devui/time-picker/src/components/time-popup/index.tsx index bba0cd3a19..8be6354235 100644 --- a/packages/devui-vue/devui/time-picker/src/components/time-popup/index.tsx +++ b/packages/devui-vue/devui/time-picker/src/components/time-popup/index.tsx @@ -99,7 +99,7 @@ export default defineComponent({ }
- +
diff --git a/packages/devui-vue/devui/time-picker/src/components/time-scroll/composables/use-time-scroll.ts b/packages/devui-vue/devui/time-picker/src/components/time-scroll/composables/use-time-scroll.ts index e1c039738c..71234bdabe 100644 --- a/packages/devui-vue/devui/time-picker/src/components/time-scroll/composables/use-time-scroll.ts +++ b/packages/devui-vue/devui/time-picker/src/components/time-scroll/composables/use-time-scroll.ts @@ -22,13 +22,14 @@ export default function useTimeScroll():any{ // 点击轨道 thumb滚动到相应位置 const clickTrackFun = (e:MouseEvent)=>{ - const offset = Math.abs(scrollTrackDom.value.getBoundingClientRect().top - e.clientY) + const offsetNum = scrollTrackDom.value.getBoundingClientRect().top - e.clientY + const offset = Math.abs(offsetNum > 0 ? 0 : offsetNum) const thumbCenter = scrollThumbDom.value.offsetHeight / 2; const thumbPosition = (offset - thumbCenter) * 100 / scrollContentDom.value.offsetHeight; scrollContentDom.value.scrollTop = (thumbPosition * scrollContentDom.value.scrollHeight / 100); scrollContentDom.value.style.top = scrollContentDom.value.scrollTop + 'px' } - + // 鼠标拖到 const mouseDownThum = ()=>{ isDown.value = true @@ -41,18 +42,14 @@ export default function useTimeScroll():any{ } const thumbMouseMove = (e:any)=>{ - const path = (e.composedPath && e.composedPath()) || e.path - if(path.includes(scrollBoxDom.value) || isDown.value){ scrollTrackDom.value.style.opacity = 1 }else{ scrollTrackDom.value.style.opacity = 0 } - if( !isDown.value ) return clickTrackFun(e) - } const getScrollWidth=()=>{ diff --git a/packages/devui-vue/devui/time-picker/src/composables/use-time-picker.ts b/packages/devui-vue/devui/time-picker/src/composables/use-time-picker.ts index 03e2fd7915..4f66a8928e 100644 --- a/packages/devui-vue/devui/time-picker/src/composables/use-time-picker.ts +++ b/packages/devui-vue/devui/time-picker/src/composables/use-time-picker.ts @@ -39,14 +39,18 @@ export default function useTimePicker( const vModelValueArr = value.split(':') const minTimeValueArr = minTime.split(':') + const maxTimeValueArr = maxTime.split(':') vModeValue.value == '' ? vModeValue.value = '00:00:00' : '' - - if( vModeValue.value > minTime ){ + + if( value > minTime && value < maxTime){ firsthandActiveTime.value = value setInputValue(vModelValueArr[0],vModelValueArr[1],vModelValueArr[2]) + }else if( value > maxTime ){ + firsthandActiveTime.value = maxTime + setInputValue(maxTimeValueArr[0],maxTimeValueArr[1],maxTimeValueArr[2]) }else{ firsthandActiveTime.value = minTime setInputValue(minTimeValueArr[0],minTimeValueArr[1],minTimeValueArr[2]) diff --git a/packages/devui-vue/devui/toast/index.ts b/packages/devui-vue/devui/toast/index.ts deleted file mode 100644 index 705ee20d4a..0000000000 --- a/packages/devui-vue/devui/toast/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { App } from 'vue' -import Toast from './src/toast' -import ToastService from './src/toast-service' - -Toast.install = function(app: App) { - app.component(Toast.name, Toast) -} - -export { Toast, ToastService } - -export default { - title: 'Toast 全局提示', - category: '反馈', - status: '100%', - install(app: App): void { - app.use(Toast as any) - app.config.globalProperties.$toastService = ToastService - } -} diff --git a/packages/devui-vue/devui/toast/src/hooks/use-toast-constant.ts b/packages/devui-vue/devui/toast/src/hooks/use-toast-constant.ts deleted file mode 100644 index af0627dec1..0000000000 --- a/packages/devui-vue/devui/toast/src/hooks/use-toast-constant.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function useToastConstant() { - const ANIMATION_NAME = 'slide-in' - const ANIMATION_TIME = 300 - const ID_PREFIX = 'toast-message' - - return { - ANIMATION_TIME, - ANIMATION_NAME, - ID_PREFIX - } as const -} diff --git a/packages/devui-vue/devui/toast/src/hooks/use-toast-event.ts b/packages/devui-vue/devui/toast/src/hooks/use-toast-event.ts deleted file mode 100644 index 45c7f16fa9..0000000000 --- a/packages/devui-vue/devui/toast/src/hooks/use-toast-event.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getCurrentInstance } from 'vue' -import { Message } from '../toast-types' -import { useToastConstant } from './use-toast-constant' - -const { ANIMATION_TIME } = useToastConstant() - -export function useToastEvent() { - const ctx = getCurrentInstance() - - function onCloseEvent(msg: Message) { - ctx.emit('closeEvent', msg) - } - - function onValueChange(msgs: Message[]) { - ctx.emit('valueChange', msgs) - } - - function onHidden() { - setTimeout(() => (ctx.attrs.onHidden as () => void)?.(), ANIMATION_TIME) - } - - return { onCloseEvent, onValueChange, onHidden } -} diff --git a/packages/devui-vue/devui/toast/src/hooks/use-toast-helper.ts b/packages/devui-vue/devui/toast/src/hooks/use-toast-helper.ts deleted file mode 100644 index a5652c0f78..0000000000 --- a/packages/devui-vue/devui/toast/src/hooks/use-toast-helper.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Message } from '../src/toast.type' - -export function useToastHelper() { - function severityDelay(msg: Message) { - switch (msg.severity) { - case 'warning': - case 'error': - return 10e3 - default: - return 5e3 - } - } - - return { severityDelay } -} diff --git a/packages/devui-vue/devui/toast/src/hooks/use-toast-z-index.ts b/packages/devui-vue/devui/toast/src/hooks/use-toast-z-index.ts deleted file mode 100644 index 22e2519012..0000000000 --- a/packages/devui-vue/devui/toast/src/hooks/use-toast-z-index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export let toastZIndex = 1060 - -export function toastIncrease() { - toastZIndex++ -} diff --git a/packages/devui-vue/devui/toast/src/toast-icon-close.tsx b/packages/devui-vue/devui/toast/src/toast-icon-close.tsx deleted file mode 100644 index 6ded5f19fb..0000000000 --- a/packages/devui-vue/devui/toast/src/toast-icon-close.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { defineComponent, PropType } from 'vue' -import { Icon } from '../../icon' - -export default defineComponent({ - name: 'DToastIconClose', - props: { - prefixCls: String, - onClick: Function as PropType<(e: MouseEvent) => void> - }, - emits: ['click'], - render() { - const { prefixCls, $emit } = this - - const wrapperCls = `${prefixCls}-icon-close` - - return ( -
$emit('click', e)}> - -
- ) - } -}) diff --git a/packages/devui-vue/devui/toast/src/toast-image.tsx b/packages/devui-vue/devui/toast/src/toast-image.tsx deleted file mode 100644 index 9bf97f2f39..0000000000 --- a/packages/devui-vue/devui/toast/src/toast-image.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { defineComponent, PropType } from 'vue' -import { IToastSeverity } from './toast-types' -import { Icon } from '../../icon' - -export default defineComponent({ - name: 'DToastImage', - props: { - prefixCls: String, - severity: String as PropType - }, - render() { - const { prefixCls, severity } = this - - const wrapperCls = [`${prefixCls}-image`, `${prefixCls}-image-${severity || 'common'}`] - - const severityIconMap = { - info: 'info-o', - success: 'right-o', - warning: 'warning-o', - error: 'error-o' - } - - const showIcon = () => severity !== 'common' - - return {showIcon() ? : null} - } -}) diff --git a/packages/devui-vue/devui/toast/src/toast-service.ts b/packages/devui-vue/devui/toast/src/toast-service.ts deleted file mode 100644 index bb72303cc2..0000000000 --- a/packages/devui-vue/devui/toast/src/toast-service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createApp, onUnmounted } from 'vue' -import { ToastProps } from './toast-types' -import Toast from './toast' - -function createToastApp(props: Record) { - return createApp(Toast, props) -} - -class ToastService { - static open(props: Partial & Pick) { - let $body: HTMLElement | null = document.body - let $div: HTMLDivElement | null = document.createElement('div') - - $body.appendChild($div) - - let app = createToastApp({ ...(props ?? {}), onHidden: () => app?.unmount() }) - let toastInstance = app.mount($div) - - onUnmounted(() => { - $body.removeChild($div) - - $body = null - $div = null - - app = null - toastInstance = null - }, toastInstance.$) - - return { - toastInstance - } - } -} - -export default ToastService diff --git a/packages/devui-vue/devui/toast/src/toast-types.ts b/packages/devui-vue/devui/toast/src/toast-types.ts deleted file mode 100644 index c77e05dcff..0000000000 --- a/packages/devui-vue/devui/toast/src/toast-types.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { CSSProperties, ExtractPropTypes, PropType, h } from 'vue' - -export type IToastLifeMode = 'single' | 'global' -export type IToastSeverity = 'common' | 'success' | 'error' | 'warning' | 'info' | string -export type IToastSeverityConfig = { color: string; icon: string; } - -export interface Message { - /** - * 消息级别。 - * 预设值有 common、success、error、warn、info,超时时间参见 life 说明, - * 未设置或非预设值时超时时间为 5000 毫秒,warn 和 error 为 10000 毫秒。 - */ - severity?: IToastSeverity - /** - * 消息标题。 - * 当设置超时时间,未设置标题时,不展示标题和关闭按钮。 - */ - summary?: string - /** - * 消息内容,推荐使用content替换。 - */ - detail?: string - /** - * 消息内容,支持纯文本和插槽,推荐使用。 - */ - content?: string | `slot:${string}` | ((message: Message) => ReturnType) - /** - * 单个消息超时时间,需设置 lifeMode 为 single 。 - * 每个消息使用自己的超时时间,开启该模式却未设置时按 severity 判断超时时间。 - */ - life?: number - /** - * 消息 ID。 - */ - id?: any -} - -export const toastProps = { - /** - * 必选,消息内容数组,Message 对象定义见下文。 - */ - value: { - type: Array as PropType, - required: true, - default: () => [] - }, - /** - * 可选,超时时间,超时后自动消失,鼠标悬停可以阻止消失,单位毫秒。 - * - * @description 普通、成功、提示类默认为 5000 毫秒,错误、警告类默认为 10000 毫秒。 - */ - life: { - type: Number, - default: null - }, - /** - * 可选,超时时间模式,预设值为 global 和 single 。 - * - * @description - * 默认为 global,所有消息使用 life 或群组第一个消息的预设超时时间; - * 设置为 single 时,每个消息使用自身的超时时间,参见 Message 中的 life 定义。 - * - * @default 'global' - */ - lifeMode: { - type: String as PropType, - default: 'global' - }, - /** - * 可选,是否常驻,默认自动关闭。 - * - * @default false - */ - sticky: { - type: Boolean, - default: false - }, - /** - * 可选,样式。 - */ - style: { - type: Object as PropType, - default: () => ({}) - }, - /** - * 可选,类名。 - */ - styleClass: { - type: String - }, - onCloseEvent: { - type: Function as PropType<(message: Message) => void> - }, - onValueChange: { - type: Function as PropType<(restMessages: Message[]) => void> - } -} as const - -export type ToastProps = ExtractPropTypes diff --git a/packages/devui-vue/devui/toast/src/toast.tsx b/packages/devui-vue/devui/toast/src/toast.tsx deleted file mode 100644 index 5f925c361d..0000000000 --- a/packages/devui-vue/devui/toast/src/toast.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import './toast.scss' - -import { computed, defineComponent, nextTick, onUnmounted, ref, watch } from 'vue' -import { Message, ToastProps, toastProps } from './toast-types' -import ToastIconClose from './toast-icon-close' -import ToastImage from './toast-image' -import { cloneDeep, isEqual, merge, omit, throttle } from 'lodash-es' -import { useToastEvent } from './hooks/use-toast-event' -import { useToastHelper } from './hooks/use-toast-helper' -import { useToastConstant } from './hooks/use-toast-constant' -import { toastZIndex, toastIncrease } from './hooks/use-toast-z-index' - -const { ANIMATION_NAME, ANIMATION_TIME, ID_PREFIX } = useToastConstant() - -export default defineComponent({ - name: 'DToast', - inheritAttrs: false, - props: toastProps, - emits: ['closeEvent', 'valueChange'], - setup(props: ToastProps, ctx) { - const { onCloseEvent, onHidden, onValueChange } = useToastEvent() - const { severityDelay } = useToastHelper() - - const removeThrottle = throttle(remove, ANIMATION_TIME) - - const messages = ref([]) - const msgAnimations = ref([]) - - const containerRef = ref() - const msgItemRefs = ref([]) - - let timestamp: number = Date.now() - let timeout: number | undefined - const timeoutArr: typeof timeout[] = [] - - const defaultLife = computed(() => { - if (props.life !== null) return props.life - - if (messages.value.length > 0) return severityDelay(messages.value[0]) - - return 5e3 - }) - - watch( - () => props.value, - (value) => { - if (value.length === 0) return - - if (hasMsgAnimation()) { - initValue() - } - - nextTick(() => { - initValue(value) - handleValueChange() - }) - }, - { deep: true, immediate: true } - ) - - watch(messages, (value) => { - value.length === 0 && msgAnimations.value.length > 0 && (msgAnimations.value = []) - }) - - watch(msgAnimations, (value, oldValue) => { - oldValue.length > 0 && value.length === 0 && onHidden() - }) - - onUnmounted(() => { - if (props.sticky) { - return - } - - if (props.lifeMode === 'single') { - timeoutArr.forEach((t) => t && clearTimeout(t)) - } else { - clearTimeout(timeout) - } - }) - - function initValue(value: Message[] = []) { - const cloneValue = cloneDeep(value) - messages.value = cloneValue.map((v, i) => merge(v, { id: `${ID_PREFIX}-${i}` })) - msgAnimations.value = [] - } - - function handleValueChange() { - toastIncrease() - - setTimeout(() => { - messages.value.forEach((msg) => msgAnimations.value.push(msg)) - }, 0) - - if (props.sticky) return - - if (timeout) { - timeout = clearTimeout(timeout) as undefined - } - - if (timeoutArr.length > 0) { - timeoutArr.splice(0).forEach((t) => clearTimeout(t)) - } - - timestamp = Date.now() - - if (props.lifeMode === 'single') { - setTimeout(() => { - messages.value.forEach((msg, i) => { - timeoutArr[i] = setTimeout(() => singleModeRemove(msg, i), msg.life || severityDelay(msg)) - }) - }) - } else { - timeout = setTimeout(() => removeAll(), defaultLife.value) - } - } - - function singleModeRemove(msg: Message, i: number) { - removeMsgAnimation(msg) - setTimeout(() => { - onCloseEvent(msg) - - if (hasMsgAnimation()) { - messages.value.splice(i, 1) - } else { - messages.value = [] - } - - onValueChange(messages.value) - }, ANIMATION_TIME) - } - - function interrupt(i: number) { - // 避免正在动画中的 toast 触发方法 - if (!msgAnimations.value.includes(messages.value[i])) return - - if (props.lifeMode === 'single') { - if (timeoutArr[i]) { - timeoutArr[i] = clearTimeout(timeoutArr[i]) as undefined - } - } else { - resetDelay(() => { - messages.value.forEach((msg, _i) => i !== _i && removeMsgAnimation(msg)) - }) - } - } - - function resetDelay(fn: () => void) { - if (!props.sticky && timeout) { - timeout = clearTimeout(timeout) as undefined - - const remainTime = defaultLife.value - (Date.now() - timestamp) - timeout = setTimeout(() => fn(), remainTime) - } - } - - function remove(i: number) { - if (props.lifeMode === 'single' && timeoutArr[i]) { - timeoutArr[i] = clearTimeout(timeoutArr[i]) as undefined - timeoutArr.splice(i, 1) - } - - removeMsgAnimation(messages.value[i]) - - setTimeout(() => { - onCloseEvent(messages.value[i]) - - messages.value.splice(i, 1) - - onValueChange(messages.value) - - if (props.lifeMode === 'global') { - removeReset() - } - }, ANIMATION_TIME) - } - - function removeAll() { - if (messages.value.length > 0) { - msgAnimations.value = [] - - setTimeout(() => { - messages.value.forEach((msg) => onCloseEvent(msg)) - - messages.value = [] - - onValueChange(messages.value) - }, ANIMATION_TIME) - } - } - - function removeReset(i?: number, msg?: Message) { - // 避免点击关闭但正在动画中或自动消失正在动画中的 toast 触发重置方法 - const removed = messages.value.findIndex((_msg) => _msg === msg) === -1 - - if (removed || (msg !== undefined && !msgAnimations.value.includes(msg))) { - return - } - - if (props.lifeMode === 'single') { - const msgLife = msg!.life || severityDelay(msg!) - const remainTime = msgLife - (Date.now() - timestamp) - timeoutArr[i!] = setTimeout(() => singleModeRemove(msg!, i!), remainTime) - } else { - resetDelay(() => removeAll()) - } - } - - function removeIndexThrottle(i: number) { - if (i < msgItemRefs.value.length && i > -1) { - removeThrottle(i) - } - } - - function removeMsgThrottle(msg: Message) { - const ignoreDiffKeys = ['id'] - const index = messages.value.findIndex((_msg) => isEqual(omit(_msg, ignoreDiffKeys), omit(msg, ignoreDiffKeys))) - removeIndexThrottle(index) - } - - function removeMsgAnimation(msg: Message) { - msgAnimations.value = msgAnimations.value.filter((_msg) => _msg !== msg) - } - - function close(params?: number | Message): void { - if (params === undefined) { - return removeAll() - } - - if (typeof params === 'number') { - removeIndexThrottle(params) - } else { - removeMsgThrottle(params) - } - } - - function msgItemRef(i: number) { - return msgItemRefs.value[i] as HTMLDivElement - } - - function hasMsgAnimation() { - return msgAnimations.value.length > 0 - } - - return { - messages, - msgAnimations, - containerRef, - msgItemRefs, - interrupt, - removeReset, - removeThrottle, - close, - msgItemRef - } - }, - render() { - const { - style: extraStyle, - styleClass: extraClass, - messages, - msgAnimations, - msgItemRefs, - life, - interrupt, - removeReset, - removeThrottle, - $attrs, - $slots - } = this - - const prefixCls = 'devui-toast' - - const wrapperStyles = [`z-index: ${toastZIndex}`, extraStyle] - const wrapperCls = [prefixCls, extraClass] - - const msgCls = (msg: Message) => [ - `${prefixCls}-item-container`, - `${prefixCls}-message-${msg.severity}`, - { [ANIMATION_NAME]: msgAnimations.includes(msg) } - ] - - const showClose = (msg: Message) => !(!msg.summary && life !== null) - const showImage = (msg: Message) => msg.severity !== 'common' - const showSummary = (msg: Message) => !!msg.summary - const showContent = (msg: Message) => !!msg.content - const showDetail = (msg: Message) => !showContent(msg) && !!msg.detail - - const msgContent = (msg: Message) => { - if (typeof msg.content === 'function') { - return msg.content(msg) - } - - if ([null, undefined].includes(msg.content)) { - return null - } - - const slotPrefix = 'slot:' - const isSlot = String(msg.content).startsWith(slotPrefix) - - if (isSlot) { - return $slots[msg.content.slice(slotPrefix.length)]?.(msg) - } - - return msg.content - } - - return ( -
- {messages.map((msg, i) => ( -
(msgItemRefs[i] = el)} - key={msg.id} - class={msgCls(msg)} - aria-live="polite" - onMouseenter={() => interrupt(i)} - onMouseleave={() => removeReset(i, msg)} - > -
- {showClose(msg) ? removeThrottle(i)} /> : null} - {showImage(msg) ? : null} -
- {showSummary(msg) ? {msg.summary} : null} - {showContent(msg) ? msgContent(msg) : null} - {showDetail(msg) ?

: null} -
-
-
- ))} -
- ) - } -}) diff --git a/packages/devui-vue/devui/tooltip/__tests__/tooltip.spec.ts b/packages/devui-vue/devui/tooltip/__tests__/tooltip.spec.ts new file mode 100644 index 0000000000..cf13e7a19d --- /dev/null +++ b/packages/devui-vue/devui/tooltip/__tests__/tooltip.spec.ts @@ -0,0 +1,175 @@ +import { mount } from '@vue/test-utils'; +import Tooltip from '../src/tooltip'; +import DButton from '../../button/src/button'; +import { Loading } from '../../loading/index' +import { nextTick } from 'vue'; + +let tooltipElement: HTMLElement; +const globalOption = { + directives: { + dLoading: Loading + } +} +const defaultslot = { + default: 'tooltip' +} + +describe('tooltip', () => { + beforeEach(() => { + jest.useFakeTimers(); + }) + describe('basic', () => { + it('should be create', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content' + }, + slots: defaultslot, + global: globalOption + }) + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(wrapper.find('.tooltip').exists()).toBe(true); + expect(wrapper.find('.tooltipcontent').text()).toBe('content'); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(150); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement.style.opacity).toBe('1'); + await wrapper.findComponent(DButton).trigger('mouseleave'); + jest.advanceTimersByTime(150); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement.style.opacity).toBe('0'); + }) + it('position should be left', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content', + position: 'left' + }, + slots: defaultslot, + global: globalOption, + attachTo: document.body + }) + await nextTick(); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(100); + await nextTick(); + console.log(wrapper.element.childNodes); + const tooltipArrowElement = wrapper.element.querySelector('.arrow') as HTMLElement; + console.log(tooltipArrowElement); + expect(tooltipArrowElement.style.borderLeft).toBe('5px solid rgb(70, 77, 110)'); + wrapper.unmount(); + }) + it('position should be top', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content', + position: 'top' + }, + slots: defaultslot, + global: globalOption, + attachTo: document.body + }) + await nextTick(); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(150); + await nextTick(); + const tooltipArrowElement = wrapper.element.querySelector('.arrow') as HTMLElement; + console.log(tooltipArrowElement.style); + expect(tooltipArrowElement.style.borderTop).toBe('5px solid rgb(70, 77, 110)'); + wrapper.unmount(); + }) + it('position should be right', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content', + position: 'right' + }, + slots: defaultslot, + global: globalOption, + attachTo: document.body + }) + await nextTick(); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(150); + await nextTick(); + const tooltipArrowElement = wrapper.element.querySelector('.arrow') as HTMLElement; + console.log(tooltipArrowElement.style); + expect(tooltipArrowElement.style.borderRight).toBe('5px solid rgb(70, 77, 110)'); + wrapper.unmount(); + }) + it('position should be bottom', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content', + position: 'bottom' + }, + slots: defaultslot, + global: globalOption, + attachTo: document.body + }) + await nextTick(); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(150); + await nextTick(); + const tooltipArrowElement = wrapper.element.querySelector('.arrow') as HTMLElement; + console.log(tooltipArrowElement.style); + expect(tooltipArrowElement.style.borderBottom).toBe('5px solid rgb(70, 77, 110)'); + wrapper.unmount() + }) + }) + describe('delay time', () => { + it('test mouseEnterDelay', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content', + mouseEnterDelay: '500' + }, + slots: defaultslot, + global: globalOption, + attachTo: document.body + }) + await nextTick(); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(200); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement).toBe(null); + jest.advanceTimersByTime(300); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement.style.opacity).toBe('1'); + wrapper.unmount(); + }) + it('test mouseLeaveDelay', async () => { + const wrapper = mount(Tooltip, { + props: { + content: 'content', + mouseLeaveDelay: '1000' + }, + slots: defaultslot, + global: globalOption, + attachTo: document.body + }) + await nextTick(); + await wrapper.findComponent(DButton).trigger('mouseenter'); + jest.advanceTimersByTime(100); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement.style.opacity).toBe('1'); + await wrapper.findComponent(DButton).trigger('mouseleave'); + jest.advanceTimersByTime(500); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement.style.opacity).toBe('1'); + jest.advanceTimersByTime(500); + await nextTick(); + tooltipElement = wrapper.element.querySelector('.tooltip') as HTMLElement; + expect(tooltipElement).toBe(null); + }) + + }) + +}) \ No newline at end of file diff --git a/packages/devui-vue/devui/tooltip/index.ts b/packages/devui-vue/devui/tooltip/index.ts index a27b320b48..a00e89ad20 100644 --- a/packages/devui-vue/devui/tooltip/index.ts +++ b/packages/devui-vue/devui/tooltip/index.ts @@ -1,17 +1,14 @@ -import type { App } from 'vue' -import Tooltip from './src/tooltip' +import type { App } from 'vue'; +import Tooltip from './src/tooltip'; +export * from './src/tooltip-types'; -Tooltip.install = function(app: App) { - app.component(Tooltip.name, Tooltip) -} - -export { Tooltip } +export { Tooltip }; export default { title: 'Tooltip提示', category: '反馈', - status: '50%', + status: '70%', install(app: App): void { - app.use(Tooltip as any) - } -} + app.component(Tooltip.name, Tooltip); + }, +}; diff --git a/packages/devui-vue/devui/tooltip/src/tooltip-types.ts b/packages/devui-vue/devui/tooltip/src/tooltip-types.ts index 545d337fab..016a1f6379 100644 --- a/packages/devui-vue/devui/tooltip/src/tooltip-types.ts +++ b/packages/devui-vue/devui/tooltip/src/tooltip-types.ts @@ -1,27 +1,36 @@ -import type { ExtractPropTypes } from 'vue' +import type { ComputedRef, ExtractPropTypes, PropType, Ref } from 'vue'; -export type TTooltip = 'top' | 'right' | 'bottom' | 'left'; +export type BasePlacement = 'top' | 'right' | 'bottom' | 'left'; export const tooltipProps = { - position: { + content: { type: String, - default: 'top' + default: '', + }, + position: { + type: [String, Array] as PropType>, + default: 'top', }, showAnimation: { type: Boolean, - default: true + default: true, }, - content: { - type: String + mouseEnterDelay: { + type: Number, + default: 150, }, mouseLeaveDelay: { - type: String, - default: '150' + type: Number, + default: 100, }, - mouseEnterDelay: { - type: String, - default: '100' - } -} as const +}; + +export type TooltipProps = ExtractPropTypes; -export type TooltipProps = ExtractPropTypes +export type UseTooltipFn = { + visible: Ref; + placement: Ref; + positionArr: ComputedRef; + overlayStyles: ComputedRef>; + onPositionChange: (pos: BasePlacement) => void; +}; diff --git a/packages/devui-vue/devui/tooltip/src/tooltip.scss b/packages/devui-vue/devui/tooltip/src/tooltip.scss index f75dc22d82..91815c9a57 100644 --- a/packages/devui-vue/devui/tooltip/src/tooltip.scss +++ b/packages/devui-vue/devui/tooltip/src/tooltip.scss @@ -1,28 +1,80 @@ -@import '../../style/theme/color'; +@import '../../styles-var/devui-var.scss'; + +.devui-tooltip-reference { + display: inline-block; +} .devui-tooltip { - box-sizing: border-box; - - .tooltip { - box-sizing: border-box; - position: absolute; - width: fit-content; - transition: all 0.5s; - - .arrow { - width: 0; - height: 0; - position: absolute; - } - - .tooltipcontent { - box-sizing: border-box; - padding: 10px; - margin-left: 10px; - border-radius: 4px; - width: fit-content; - background-color: $devui-feedback-overlay-bg; - color: $devui-feedback-overlay-text; + max-width: 200px; + min-height: 26px; + padding: 4px 16px; + font-size: $devui-font-size; + color: $devui-feedback-overlay-text; + letter-spacing: 0; + line-height: 1.5; + background: $devui-feedback-overlay-bg; + box-shadow: none; + overflow-wrap: break-word; + word-break: break-word; + word-wrap: break-word; + text-align: start; + border-radius: $devui-border-radius-feedback; + font-style: normal; + font-weight: normal; + line-break: auto; + text-decoration: none; + text-shadow: none; + text-transform: none; + word-spacing: normal; + white-space: normal; + opacity: 1; + z-index: $devui-z-index-pop-up; +} + +.devui-tooltip-fade { + &-bottom, + &-top { + &-enter-from, + &-leave-to { + opacity: 0.8; + transform: scaleY(0.8); + } + + &-enter-to, + &-leave-from { + opacity: 1; + transform: scaleY(1); + } + + &-enter-active { + transition: transform 0.1s cubic-bezier(0.16, 0.75, 0.5, 1), opacity 0.1s cubic-bezier(0.16, 0.75, 0.5, 1); + } + + &-leave-active { + transition: transform 0.1s cubic-bezier(0.5, 0, 0.84, 0.25), opacity 0.1s cubic-bezier(0.5, 0, 0.84, 0.25); + } + } + + &-left, + &-right { + &-enter-from, + &-leave-to { + opacity: 0.8; + transform: scaleX(0.8); + } + + &-enter-to, + &-leave-from { + opacity: 1; + transform: scaleX(1); + } + + &-enter-active { + transition: transform 0.1s cubic-bezier(0.16, 0.75, 0.5, 1), opacity 0.1s cubic-bezier(0.16, 0.75, 0.5, 1); + } + + &-leave-active { + transition: transform 0.1s cubic-bezier(0.5, 0, 0.84, 0.25), opacity 0.1s cubic-bezier(0.5, 0, 0.84, 0.25); } } } diff --git a/packages/devui-vue/devui/tooltip/src/tooltip.tsx b/packages/devui-vue/devui/tooltip/src/tooltip.tsx index d26f5506c1..a949b3947d 100644 --- a/packages/devui-vue/devui/tooltip/src/tooltip.tsx +++ b/packages/devui-vue/devui/tooltip/src/tooltip.tsx @@ -1,161 +1,40 @@ -import { defineComponent, reactive, ref, watch, onMounted, getCurrentInstance, onBeforeUnmount, renderSlot, useSlots} from 'vue' -import { tooltipProps } from './tooltip-types' -import EventListener from '../utils/event-listener' -import './tooltip.scss' +import { defineComponent, ref, Teleport, toRefs, Transition } from 'vue'; +import { FlexibleOverlay } from '../../overlay'; +import { TooltipProps, tooltipProps } from './tooltip-types'; +import { useTooltip } from './use-tooltip'; +import './tooltip.scss'; export default defineComponent({ - name: 'DTooltip', - props: tooltipProps, - setup(props, ctx){ - const position = reactive({ - left: 0, - top: 0 - }) - const show = ref(false) - - // slotElement元素的ref - const slotElement = ref(null) - // tooltip元素的引用 - const tooltip = ref(null) - // arrow元素的引用 - const arrow = ref(null) - // tooltipcontent的引用 - const tooltipcontent = ref(null) - - let enterEvent - let leaveEvent - - const arrowStyle = (attr, value)=>{ - arrow.value.style[attr] = value - } - - // 延迟显示 - const delayShowTrue = function (fn, delay=props.mouseEnterDelay){ - let start - if (parseInt(delay)>=0){ - return function (){ - if (start){ - clearTimeout(start) - } - start = setTimeout(fn, parseInt(delay)) - } - } else{ - console.error('the value of delay is bigger than 0 and the type of delay must be string!') - return - } - } - // 延迟消失 - const delayShowFalse = function (fn, delay=props.mouseLeaveDelay){ - if (show.value && parseInt(delay) >= 0){ - setTimeout(fn, parseInt(delay)) - } - } - - onMounted(()=>{ - // 组件初始化不渲染tooltip - if (!show.value){ - tooltip.value.style.opacity = '0' - } - - // 注册鼠标引入事件 - /*enterEvent = EventListener.listen(slotElement.value.children[0], 'mouseenter', function (){ - show.value = true - })*/ - enterEvent = EventListener.listen(slotElement.value.children[0], 'mouseenter', delayShowTrue(function (){ - show.value = true - }, props.mouseEnterDelay)) - - // 注册鼠标移除事件 - leaveEvent = EventListener.listen(slotElement.value.children[0], 'mouseleave', function (){ - if (show.value){ - setTimeout(function (){ - show.value = false - }, props.mouseLeaveDelay) - } - }) - }) - - watch(show, function (newValue, oldValue){ - if (newValue){ - // 鼠标悬浮为true,显示提示框 - tooltip.value.style.opacity = '1' - tooltip.value.style.zIndex = '999' - arrow.value.style.border = '5px solid transparent' - // 具体的判定规则 - switch (props.position){ - case 'top': - // 设置 tooltip 内容的样式 - position.left = (slotElement.value.children[0].offsetLeft - tooltip.value.offsetWidth / 2 + slotElement.value.children[0].offsetWidth / 2) - 5; - position.top = slotElement.value.children[0].offsetTop - 10 - tooltipcontent.value.offsetHeight - // 设置箭头的样式 - arrowStyle('borderTop', '5px solid rgb(70, 77, 110)') - arrow.value.style.top = `${tooltipcontent.value.offsetHeight}px` - arrow.value.style.left = `${tooltipcontent.value.offsetWidth/2 + 5}px` - break - - case 'right': - // 设置tooltip 内容的样式 - position.left = slotElement.value.children[0].offsetLeft + slotElement.value.children[0].offsetWidth - position.top = slotElement.value.children[0].offsetTop + slotElement.value.children[0].offsetHeight/2 - tooltipcontent.value.offsetHeight/2 - // 设置箭头的样式 - arrowStyle('borderRight', '5px solid rgb(70, 77, 110)') - arrow.value.style.top = `${tooltipcontent.value.offsetHeight/2 - 5}px` - arrow.value.style.left = '-0px' - break - - case 'bottom': - // 设置tooltip的样式 - position.top = slotElement.value.children[0].offsetHeight + slotElement.value.children[0].offsetTop + 10 - position.left = (slotElement.value.children[0].offsetLeft + slotElement.value.children[0].offsetWidth/2 - tooltipcontent.value.offsetWidth/2) - 5; - // 设置arrow.value的样式 - arrowStyle('borderBottom', '5px solid rgb(70, 77, 110)') - arrow.value.style.top = '-10px' - arrow.value.style.left = `${tooltipcontent.value.offsetWidth/2 + 5}px` - break - - case 'left': - position.top = slotElement.value.children[0].offsetTop + slotElement.value.children[0].offsetHeight/2 - tooltipcontent.value.offsetHeight/2 - position.left = slotElement.value.children[0].offsetLeft - 20 - tooltipcontent.value.offsetWidth - // 设置arrow.value的样式 - arrowStyle('borderLeft', '5px solid rgb(70, 77, 110)') - arrow.value.style.left = `${tooltipcontent.value.offsetWidth + 10}px` - arrow.value.style.top = `${tooltipcontent.value.offsetHeight/2 - 5}px` - break - - default: - console.error('The attribute position value is wrong, the value is one of top、right、left、bottom') - break - } - tooltip.value.style.top = position.top + 'px' - tooltip.value.style.left = position.left + 'px' - } else { - position.top = 0 - position.left = 0 - // 鼠标移走为false,隐藏提示框 - tooltip.value.style.opacity = '0' - } - }) - - onBeforeUnmount (()=>{ - enterEvent.remove() - leaveEvent.remove() - }) - - return ()=>{ - const defaultSlot = renderSlot(useSlots(), 'default') - return ( -
-
- {defaultSlot} -
-
-
-
- {props.content} -
-
-
- ) - } - } -}) \ No newline at end of file + name: 'DTooltip', + props: tooltipProps, + setup(props: TooltipProps, { slots }) { + const { showAnimation, content } = toRefs(props); + const origin = ref(); + const tooltipRef = ref(); + const { visible, placement, positionArr, overlayStyles, onPositionChange } = useTooltip(origin, props); + + return () => ( + <> +
+ {slots.default?.()} +
+ + + + + + + + + ); + }, +}); diff --git a/packages/devui-vue/devui/tooltip/src/use-tooltip.ts b/packages/devui-vue/devui/tooltip/src/use-tooltip.ts new file mode 100644 index 0000000000..759383c60d --- /dev/null +++ b/packages/devui-vue/devui/tooltip/src/use-tooltip.ts @@ -0,0 +1,47 @@ +import { onMounted, ref, toRefs, computed } from 'vue'; +import type { Ref } from 'vue'; +import { debounce } from 'lodash'; +import { TooltipProps, BasePlacement, UseTooltipFn } from './tooltip-types'; + +const TransformOriginMap: Record = { + top: '50% calc(100% + 8px)', + bottom: '50% -8px', + left: 'calc(100% + 8px)', + right: '-8px 50%', +}; + +export function useTooltip(origin: Ref, props: TooltipProps): UseTooltipFn { + const { position, mouseEnterDelay, mouseLeaveDelay } = toRefs(props); + const visible = ref(false); + const isEnter = ref(false); + const positionArr = computed(() => (typeof position.value === 'string' ? [position.value] : position.value)); + const placement = ref(positionArr.value[0]); + const overlayStyles = computed(() => ({ + transformOrigin: TransformOriginMap[placement.value], + })); + const enter = debounce(() => { + isEnter.value && (visible.value = true); + }, mouseEnterDelay.value); + const leave = debounce(() => { + !isEnter.value && (visible.value = false); + }, mouseLeaveDelay.value); + + const onMouseenter = () => { + isEnter.value = true; + enter(); + }; + const onMouseleave = () => { + isEnter.value = false; + leave(); + }; + const onPositionChange = (pos: BasePlacement) => { + placement.value = pos; + }; + + onMounted(() => { + origin.value.addEventListener('mouseenter', onMouseenter); + origin.value.addEventListener('mouseleave', onMouseleave); + }); + + return { visible, placement, positionArr, overlayStyles, onPositionChange }; +} diff --git a/packages/devui-vue/devui/tooltip/utils/event-listener.js b/packages/devui-vue/devui/tooltip/utils/event-listener.js deleted file mode 100644 index 73f6f68c53..0000000000 --- a/packages/devui-vue/devui/tooltip/utils/event-listener.js +++ /dev/null @@ -1,21 +0,0 @@ -const EventListener = { - listen: function (target, eventType, callback) { - if (target.addEventListener){ - target.addEventListener(eventType, callback, false); - return { - remove (){ - target.removeEventListener(target, callback, false); - } - } - } else { - target.attchEvent(eventType, callback); - return { - remove (){ - target.detachEvent(eventType, callback); - } - }; - } - } -}; - -export default EventListener; \ No newline at end of file diff --git a/packages/devui-vue/devui/transfer/__tests__/transfer.spec.ts b/packages/devui-vue/devui/transfer/__tests__/transfer.spec.ts new file mode 100644 index 0000000000..12d78fb110 --- /dev/null +++ b/packages/devui-vue/devui/transfer/__tests__/transfer.spec.ts @@ -0,0 +1,438 @@ +import { mount } from '@vue/test-utils'; +import { ref, nextTick } from 'vue' +import DCheckbox from '../../checkbox/src/checkbox'; +import DTooltip from '../../tooltip/src/tooltip'; +import DTransfer from '../src/transfer' + +const SOURCE_DATA = [ + { + key: '北京', + value: '北京', + disabled: false, + }, + { + key: '上海', + value: '上海', + disabled: true, + }, + { + key: '广州', + value: '广州', + disabled: true, + }, + { + key: '深圳', + value: '深圳', + disabled: false, + }, + { + key: '成都', + value: '成都', + disabled: false, + }, + { + key: '武汉', + value: '武汉', + disabled: false, + }, + { + key: '西安', + value: '西安', + disabled: false, + }, + { + key: '福建', + value: '福建', + disabled: false, + } +] +const TARGET_DATA = [ + { + key: '南充', + value: '南充', + disabled: false, + }, + { + key: '广元', + value: '广元', + disabled: false, + }, + { + key: '绵阳', + value: '绵阳', + disabled: false, + }, + { + key: '大连', + value: '大连', + disabled: false, + }, + { + key: '重庆', + value: '重庆', + disabled: false, + } +] + +describe('d-transfer', () => { + it('d-transfer basic work', async () => { + const sourceOption = ref(SOURCE_DATA) + const targetOption = ref(TARGET_DATA) + const wrapper = mount({ + components: { + DTransfer + }, + template: ` + + + `, + setup() { + return { + modelValues: ref(['成都', '绵阳']), + titles: ref(['sourceHeader', 'targetHeader']), + source: sourceOption, + target: targetOption + } + } + }) + + /** + * 测试穿梭框源是否正确渲染 start + */ + expect(wrapper.find('.devui-transfer').exists()).toBeTruthy() + expect(wrapper.findAll('.devui-transfer-source .devui-transfer-panel-body .devui-checkbox').length).toBe(8) + expect(wrapper.findAll('.devui-transfer-target .devui-transfer-panel-body .devui-checkbox').length).toBe(5) + /** + * 测试穿梭框源是否正确渲染 end + */ + + + /** + * 测试穿梭框源数据中disable start + */ + const disableds = wrapper.findAll('.devui-transfer .devui-transfer-source .disabled') + expect(disableds.length).toBe(2) + expect(disableds.filter(item => ['上海', '广州'].includes(item.text())).length).toBe(2) + /** + * 测试穿梭框源数据中disable end + */ + + + /** + * 测试穿梭框默认选中值 start + */ + await nextTick() + const sourceChecked = wrapper.find('.devui-transfer-source .active') + expect(sourceChecked.text()).toBe('成都') + const targetChecked = wrapper.find('.devui-transfer-target .active') + expect(targetChecked.text()).toBe('绵阳') + /** + * 测试穿梭框默认选中值 end + */ + + + /** + * 测试穿梭框左右穿梭 start + */ + // 源按钮 + const leftButton = wrapper.find('.devui-transfer .devui-transfer-panel-operation-group-left button') + expect(leftButton) + expect(leftButton.attributes('disabled')).toEqual(undefined) + leftButton.trigger('click') + await nextTick() + expect(leftButton.attributes('disabled')).toEqual('') + // 目标按钮 + const rightButton = wrapper.find('.devui-transfer .devui-transfer-panel-operation-group-right button') + expect(rightButton) + expect(rightButton.attributes('disabled')).toEqual(undefined) + rightButton.trigger('click') + await nextTick() + expect(rightButton.attributes('disabled')).toEqual('') + /** + * 测试穿梭框左右穿梭 end + */ + + + /** + * 测试穿梭框左、右全选 start + */ + // 源全选 + const sourceAllInput = wrapper.find('.devui-transfer-source .devui-transfer-panel-header-allChecked .devui-checkbox-input') + sourceAllInput.trigger('click') + await nextTick() + const newSourceAllInput = wrapper.find('.devui-transfer-source .devui-transfer-panel-header-allChecked .devui-checkbox-input') + expect(newSourceAllInput.element.checked).toBeTruthy() + // 目标全选 + const targetAllInput = wrapper.find('.devui-transfer-target .devui-transfer-panel-header-allChecked .devui-checkbox-input') + targetAllInput.trigger('click') + await nextTick() + const newTargetAllInput = wrapper.find('.devui-transfer-target .devui-transfer-panel-header-allChecked .devui-checkbox-input') + expect(newTargetAllInput.element.checked).toBeTruthy() + /** + * 测试穿梭框左、右全选 end + */ + }) + + it('d-transfer searching work', async () => { + const sourceOption = ref(SOURCE_DATA) + const targetOption = ref(TARGET_DATA) + const wrapper = mount({ + components: { + DTransfer + }, + template: ` + + + `, + setup() { + return { + modelValues: ref(['成都', '绵阳']), + titles: ref(['sourceHeader', 'targetHeader']), + source: sourceOption, + target: targetOption, + isSearch: ref(true) + } + } + }) + + /** + * 测试搜索功能 start + */ + // 源搜索功能 + expect(wrapper.find('.devui-transfer-source .devui-search').exists()).toBe(true) + const sourceSearch = wrapper.find('.devui-transfer-source .devui-search input[type="text"]') + const sourceSearchClear = wrapper.find('.devui-transfer-source .devui-search .devui-search__clear') + expect(sourceSearchClear.exists()).toBe(false) + sourceSearch.setValue('成都') + await nextTick() + expect(sourceSearch.element.value).toBe('成都') + expect(wrapper.find('.devui-transfer-source .devui-transfer-panel-body .devui-checkbox').text()).toBe('成都') + const newSourceSearchClear = wrapper.find('.devui-transfer-source .devui-search .devui-search__clear') + expect(newSourceSearchClear.exists()).toBe(true) + newSourceSearchClear.trigger('click') + await nextTick() + expect(wrapper.find('.devui-transfer-source .devui-search input[type="text"]').element.value).toBe('') + + // 目标搜索功能 + expect(wrapper.find('.devui-transfer-target .devui-search').exists()).toBe(true) + const targetSearch = wrapper.find('.devui-transfer-target .devui-search input[type="text"]') + const targetSearchClear = wrapper.find('.devui-transfer-target .devui-search .devui-search__clear') + expect(targetSearchClear.exists()).toBe(false) + targetSearch.setValue('广元') + await nextTick() + expect(targetSearch.element.value).toBe('广元') + expect(wrapper.find('.devui-transfer-target .devui-transfer-panel-body .devui-checkbox').text()).toBe('广元') + const newTargetSearchClear = wrapper.find('.devui-transfer-target .devui-search .devui-search__clear') + expect(newTargetSearchClear.exists()).toBe(true) + newTargetSearchClear.trigger('click') + await nextTick() + expect(wrapper.find('.devui-transfer-target .devui-search input[type="text"]').element.value).toBe('') + /** + * 测试搜索功能 end + */ + + }) + + // it('d-transfer tooltips work', async () => { + // const sourceOption = ref(SOURCE_DATA) + // const targetOption = ref(TARGET_DATA) + // const wrapper = mount({ + // components: { + // DTransfer + // }, + // template: ` + // + // + // `, + // setup() { + // return { + // modelValues: ref(['成都', '绵阳']), + // titles: ref(['sourceHeader', 'targetHeader']), + // source: sourceOption, + // target: targetOption, + // isShowTooltip: ref(true) + // } + // } + // }) + + // /** + // * 测试穿梭框渲染 start + // */ + // expect(wrapper.find('.devui-transfer-source').exists()).toBe(true) + // expect(wrapper.find('.devui-transfer-target').exists()).toBe(true) + // expect(wrapper.findAll('.devui-transfer-source .devui-tooltip').length).toBe(8) + // expect(wrapper.findAll('.devui-transfer-source .devui-transfer-panel-body .devui-checkbox').length).toBe(8) + // /** + // * 测试穿梭框渲染 end + // */ + + + // /** + // * 测试穿梭框tooltip start + // */ + // expect(wrapper.find('.devui-transfer-source').exists()).toBe(true) + // expect(wrapper.find('.devui-transfer-target').exists()).toBe(true) + // expect(wrapper.findAll('.devui-transfer-source .devui-transfer-panel-body .devui-checkbox').length).toBe(8) + // expect(wrapper.findAll('.devui-transfer-target .devui-transfer-panel-body .devui-checkbox').length).toBe(5) + // const sourceTooltips = wrapper.findAll('.devui-transfer-source .devui-tooltip') + // const targetTooltips = wrapper.findAll('.devui-transfer-target .devui-tooltip') + // expect(sourceTooltips.length).toBe(8) + // expect(targetTooltips.length).toBe(5) + // expect(sourceTooltips[0].find('.tooltip').exists()).toBe(false) + + + // const sourceBody = wrapper.find('.devui-transfer-source .devui-transfer-panel-body') + // const checkboxC = sourceBody.findComponent(DTooltip) + // const slotElement = checkboxC.find('.devui-checkbox-column-margin') + // slotElement.trigger('mouseenter') + // console.log(slotElement.classes()) + // await nextTick() + // console.log(checkboxC.find('.tooltip')) + + + + // /** + // * 测试穿梭框tooltip end + // */ + // }) + + it('d-transfer source drag work', async () => { + const sourceOption = ref(SOURCE_DATA) + const targetOption = ref(TARGET_DATA) + const wrapper = mount({ + components: { + DTransfer + }, + template: ` + + + `, + setup() { + return { + modelValues: ref(['成都', '绵阳']), + titles: ref(['sourceHeader', 'targetHeader']), + source: sourceOption, + target: targetOption, + isSourceDroppable: ref(true) + } + } + }) + + + // /** + // * 测试穿梭框拖拽排序 start + // */ + // const startDragItemIndex = sourceOption.value.findIndex(item => item.value === '成都') + // const startDropItemIndex = sourceOption.value.findIndex(item => item.value === '上海') + // const dragItemValue = sourceOption.value[startDragItemIndex] + // const dropItemValue = sourceOption.value[startDropItemIndex] + // const dataSort = (target, dragItem, dropItem) => { + // const startIndex = target.findIndex(item => item.key === dragItem.key) + // const endIndex = target.findIndex(item => item.key === dropItem.key) + // target.splice(endIndex, 1, dragItem) + // target.splice(startIndex, 1, dropItem) + // } + // dataSort(sourceOption.value, dragItemValue, dropItemValue) + // await nextTick() + // const endDragItemIndex = sourceOption.value.findIndex(item => item.value === '成都') + // const endDropItemIndex = sourceOption.value.findIndex(item => item.value === '上海') + // // 4 1 // 1 4 + // /** + // * 测试穿梭框拖拽排序 end + // */ + /** + * 测试穿梭框拖拽排序 start + */ + const left = wrapper.find('.devui-transfer-source') + const leftTransfer = left.findComponent({ name: 'DTransferBase' }) + const leftOption = leftTransfer.props().sourceOption + const startDragItemIndex = leftOption.findIndex(item => item.value === '成都') + const startDropItemIndex = leftOption.findIndex(item => item.value === '上海') + expect(startDragItemIndex).toBe(4) + expect(startDropItemIndex).toBe(1) + leftTransfer.props().onDragend(leftOption[startDragItemIndex], leftOption[startDropItemIndex]) + await nextTick() + const endDragItemIndex = leftOption.findIndex(item => item.value === '成都') + const endDropItemIndex = leftOption.findIndex(item => item.value === '上海') + expect(endDragItemIndex).toBe(1) + expect(endDropItemIndex).toBe(4) + /** + * 测试穿梭框拖拽排序 end + */ + }) + + it('d-transfer target drag work', async () => { + const sourceOption = ref(SOURCE_DATA) + const targetOption = ref(TARGET_DATA) + const wrapper = mount({ + components: { + DTransfer + }, + template: ` + + + `, + setup() { + return { + modelValues: ref(['成都', '绵阳']), + titles: ref(['sourceHeader', 'targetHeader']), + source: sourceOption, + target: targetOption, + isSourceDroppable: ref(true) + } + } + }) + + + /** + * 测试穿梭框拖拽排序 start + */ + const transfer = wrapper.findComponent({ name: 'DTransfer' }) + const rightTransfer = wrapper.find('.devui-transfer-target').findComponent({ name: 'DTransferBase' }) + const rightOption = transfer.props().targetOption + const startDragItemIndex = rightOption.findIndex(item => item.value === '大连') + const startDropItemIndex = rightOption.findIndex(item => item.value === '广元') + expect(startDragItemIndex).toBe(3) + expect(startDropItemIndex).toBe(1) + rightTransfer.props().onDragend(rightOption[startDragItemIndex], rightOption[startDropItemIndex]) + await nextTick() + const endDragItemIndex = rightOption.findIndex(item => item.value === '大连') + const endDropItemIndex = rightOption.findIndex(item => item.value === '广元') + expect(endDragItemIndex).toBe(3) + expect(endDropItemIndex).toBe(1) + /** + * 测试穿梭框拖拽排序 end + */ + }) +}) diff --git a/packages/devui-vue/devui/transfer/common/use-transfer-base.ts b/packages/devui-vue/devui/transfer/common/use-transfer-base.ts index 4c3b613112..d79f3b5011 100644 --- a/packages/devui-vue/devui/transfer/common/use-transfer-base.ts +++ b/packages/devui-vue/devui/transfer/common/use-transfer-base.ts @@ -1,10 +1,13 @@ import { computed, ExtractPropTypes, PropType, ComputedRef } from 'vue' import { IItem, TState, TResult } from '../types' import { TransferProps } from './use-transfer' +import { transferCommon, transferDragFunctions } from './use-transfer-common' export type TransferOperationProps = ExtractPropTypes export const transferBaseProps = { + ...transferCommon, + ...transferDragFunctions, sourceOption: { type: Array as () => IItem[], default(): Array { @@ -57,13 +60,13 @@ export const transferBaseProps = { type: Number, default: (): number => 0 }, - showTooltip: { + isSourceDroppable: { type: Boolean, default: (): boolean => false }, - tooltipPosition: { - type: String as PropType<'top' | 'right' | 'bottom' | 'left'>, - default: (): string => 'top' + isTargetDroppable: { + type: Boolean, + default: (): boolean => false }, scopedSlots: { type: Object @@ -76,6 +79,9 @@ export const transferBaseProps = { }, onUpdateCheckeds: { type: Function as PropType<(val: string[]) => void> + }, + onDragend: { + type: Function as unknown as () => ((dragItem: IItem, dropItem: IItem) => void) } } diff --git a/packages/devui-vue/devui/transfer/common/use-transfer-checkbox.ts b/packages/devui-vue/devui/transfer/common/use-transfer-checkbox.ts new file mode 100644 index 0000000000..1507a161ad --- /dev/null +++ b/packages/devui-vue/devui/transfer/common/use-transfer-checkbox.ts @@ -0,0 +1,17 @@ +import { ExtractPropTypes } from 'vue' +import { IItem } from '../types' +import { transferCommon } from './use-transfer-common' + +const transferCheckboxProps = { + ...transferCommon, + data: { + type: Object as () => IItem, + }, + id: { + type: Number + } +} + +export type TransferCheckboxProps = ExtractPropTypes + +export default transferCheckboxProps \ No newline at end of file diff --git a/packages/devui-vue/devui/transfer/common/use-transfer-common.ts b/packages/devui-vue/devui/transfer/common/use-transfer-common.ts new file mode 100644 index 0000000000..0ad911eb31 --- /dev/null +++ b/packages/devui-vue/devui/transfer/common/use-transfer-common.ts @@ -0,0 +1,34 @@ +import { PropType } from 'vue' +import { IItem } from '../types' + +export const transferCommon = { + showTooltip: { + type: Boolean, + default: (): boolean => false + }, + tooltipPosition: { + type: String as PropType<'top' | 'right' | 'bottom' | 'left'>, + default: (): string => 'top' + } +} + +export const transferDragFunctions = { + onDragstart: { + type: Function as unknown as () => ((event: Event, dragItem: IItem) => void) + }, + onDrop: { + type: Function as unknown as () => ((event: Event, dropItem: IItem) => void) + }, + onDragleave: { + type: Function as unknown as () => ((event: Event, dragItem: IItem) => void) + }, + onDragover: { + type: Function as unknown as () => ((event: Event, dragItem: IItem) => void) + }, + onDragenter: { + type: Function as unknown as () => ((event: Event, dragItem: IItem) => void) + }, + onDragend: { + type: Function as unknown as () => ((event: Event, dropItem: IItem) => void) + } +} diff --git a/packages/devui-vue/devui/transfer/common/use-transfer-drag.ts b/packages/devui-vue/devui/transfer/common/use-transfer-drag.ts new file mode 100644 index 0000000000..be1f9f88f6 --- /dev/null +++ b/packages/devui-vue/devui/transfer/common/use-transfer-drag.ts @@ -0,0 +1,20 @@ +import { PropType, ExtractPropTypes } from '@vue/runtime-core' +import { IItem } from '../types' +import { transferCommon, transferDragFunctions } from './use-transfer-common' + +const transferDragProps = { + ...transferCommon, + ...transferDragFunctions, + itemData: { + type: Object as PropType + }, + id: { + type: Number, + default: (): number | null => null + }, + +} + +export type TransferDragProps = ExtractPropTypes + +export default transferDragProps \ No newline at end of file diff --git a/packages/devui-vue/devui/transfer/common/use-transfer.ts b/packages/devui-vue/devui/transfer/common/use-transfer.ts index 414d2dfc5b..566d8546bb 100644 --- a/packages/devui-vue/devui/transfer/common/use-transfer.ts +++ b/packages/devui-vue/devui/transfer/common/use-transfer.ts @@ -1,7 +1,9 @@ import { ExtractPropTypes, PropType, SetupContext } from 'vue' import { IItem, ITitles, IModel, TState } from '../types' +import { transferCommon } from './use-transfer-common' export const transferProps = { + ...transferCommon, sourceOption: { type: Array as () => IItem[], require: true, @@ -44,14 +46,6 @@ export const transferProps = { type: Boolean, default: (): boolean => false }, - showTooltip: { - type: Boolean, - default: (): boolean => false - }, - tooltipPosition: { - type: String as PropType<'top' | 'right' | 'bottom' | 'left'>, - default: (): string => 'top' - }, beforeTransfer: { type: Function as unknown as () => ((sourceOption: TState, targetOption: TState) => boolean | Promise) }, @@ -72,6 +66,9 @@ export const transferProps = { }, afterTransfer: { type: Function as unknown as () => ((targetOption: TState) => void) + }, + onDragend: { + type: Function as unknown as () => ((direction: string, dragItem: IItem, dropItem: IItem) => void) } } diff --git a/packages/devui-vue/devui/transfer/index.ts b/packages/devui-vue/devui/transfer/index.ts index 2776601564..fb99c5b75d 100644 --- a/packages/devui-vue/devui/transfer/index.ts +++ b/packages/devui-vue/devui/transfer/index.ts @@ -2,7 +2,7 @@ import type { App } from 'vue' import Transfer from './src/transfer' Transfer.install = function (app: App) { - app.component(Transfer.name, Transfer) + app.component(Transfer.name, Transfer) } export { Transfer } @@ -10,7 +10,7 @@ export { Transfer } export default { title: 'Transfer 穿梭框', category: '数据录入', - status: '10%', + status: '40%', install(app: App): void { app.use(Transfer as any) } diff --git a/packages/devui-vue/devui/transfer/src/transfer-base.tsx b/packages/devui-vue/devui/transfer/src/transfer-base.tsx index 10ec9f607d..29411f83f5 100644 --- a/packages/devui-vue/devui/transfer/src/transfer-base.tsx +++ b/packages/devui-vue/devui/transfer/src/transfer-base.tsx @@ -1,51 +1,108 @@ -import { defineComponent, computed } from 'vue' +import { defineComponent, computed, ref, watch, SetupContext } from 'vue' import { transferBaseProps, TransferBaseClass, TransferBaseProps } from '../common/use-transfer-base' import DCheckbox from '../../checkbox/src/checkbox' import DCheckboxGroup from '../../checkbox/src/checkbox-group' import DSearch from '../../search/src/search' -import DTooltip from '../../tooltip/src/tooltip' +import DTransferDrag from './transfer-drag-item' +import DTransfeCheckbox from './transfer-checkbox' export default defineComponent({ name: 'DTransferBase', components: { DSearch, DCheckboxGroup, DCheckbox, - DTooltip + DTransferDrag, + DTransfeCheckbox }, props: transferBaseProps, - setup(props: TransferBaseProps, ctx) { + setup(props: TransferBaseProps, ctx: SetupContext) { /** data start **/ + const allHalfchecked = ref(false)//ref(props.allChecked) const modelValues = computed(() => props.checkedValues as Array) + const dragWrapClass = computed(() => { + const isDrag = props.isSourceDroppable || props.isTargetDroppable + return `devui-transfer-panel-body-list devui-transfer-panel-body-${isDrag ? '' : 'no'}drag` + }) const searchQuery = computed(() => props.filter) const baseClass = TransferBaseClass(props) + const dropItem = ref(null) + /** data end **/ /** watch start **/ + watch( + () => props.checkedNum, + (nVal) => { + if (props.allChecked) { + allHalfchecked.value = !props.allChecked + } else { + allHalfchecked.value = nVal !== 0 + } + }, + { + immediate: true + } + ) /** watch end **/ /** methods start **/ const updateSearchQuery = (val: string): void => ctx.emit('changeQuery', val) - const renderCheckbox = (props, key, showTooltip = false, tooltipPosition = 'top') => { - const checkbox = - - return !showTooltip ? checkbox : {checkbox} + const renderCheckboxGroup = () => { + return ctx.emit('updateCheckeds', values) + }> + { + props.sourceOption.map((item, idx) => { + return + + }) + } + } + + const renderDragCheckboxGroup = () => { + return ctx.emit('updateCheckeds', values) + }> + { + props.sourceOption.map((item, idx) => { + return { + dropItem.value = item + }} + onDragend={(event, dragItem) => { + props.onDragend && props.onDragend(dragItem, dropItem.value) + }} /> + }) + } + + } + /** methods start **/ return { baseClass, searchQuery, + dragWrapClass, modelValues, + dropItem, + allHalfchecked, updateSearchQuery, - renderCheckbox + renderCheckboxGroup, + renderDragCheckboxGroup } }, render() { @@ -54,16 +111,18 @@ export default defineComponent({ baseClass, checkedNum, allChecked, + allHalfchecked, sourceOption, allCount, updateSearchQuery, search, searchQuery, - modelValues, + dragWrapClass, height, - showTooltip, - tooltipPosition, - renderCheckbox, + isSourceDroppable, + isTargetDroppable, + renderCheckboxGroup, + renderDragCheckboxGroup } = this return ( @@ -72,8 +131,11 @@ export default defineComponent({ this.$slots.header ? this.$slots.header() : (
this.$emit('changeAllSource', value)}> + onChange={(value: boolean) => { + this.$emit('changeAllSource', value) + }}> {title}
@@ -86,19 +148,11 @@ export default defineComponent({ {search && } -
+
{ - sourceOption.length ? this.$emit('updateCheckeds', values)}> - { - sourceOption.map((item, idx) => { - return renderCheckbox(item, idx, showTooltip, tooltipPosition) - }) - } - : -
无数据
+ sourceOption.length ? + (isSourceDroppable || isTargetDroppable ? renderDragCheckboxGroup() : renderCheckboxGroup()) + :
无数据
}
diff --git a/packages/devui-vue/devui/transfer/src/transfer-checkbox.tsx b/packages/devui-vue/devui/transfer/src/transfer-checkbox.tsx new file mode 100644 index 0000000000..554277da83 --- /dev/null +++ b/packages/devui-vue/devui/transfer/src/transfer-checkbox.tsx @@ -0,0 +1,42 @@ +import { defineComponent } from 'vue' +import DCheckbox from '../../checkbox/src/checkbox' +import DTooltip from '../../tooltip/src/tooltip' +import transferCheckboxProps, { TransferCheckboxProps } from '../common/use-transfer-checkbox' + +export default defineComponent({ + name: 'DTransferCheckbox', + components: { + DCheckbox, + DTooltip + }, + props: transferCheckboxProps, + setup(props: TransferCheckboxProps) { + /** data start **/ + const renderCheckbox = () => { + return + + } + /** data end **/ + + /** watch start **/ + /** watch end **/ + + /** methods start **/ + /** methods end **/ + + return () => { + return ( + !props.showTooltip ? renderCheckbox() : + {renderCheckbox()} + + ) + } + } +}) \ No newline at end of file diff --git a/packages/devui-vue/devui/transfer/src/transfer-drag-item.tsx b/packages/devui-vue/devui/transfer/src/transfer-drag-item.tsx new file mode 100644 index 0000000000..bb104fd3c4 --- /dev/null +++ b/packages/devui-vue/devui/transfer/src/transfer-drag-item.tsx @@ -0,0 +1,134 @@ +import { defineComponent, ref, onUnmounted } from 'vue' +import transferDragProps, { TransferDragProps } from '../common/use-transfer-drag' +import DTransfeCheckbox from './transfer-checkbox' +import '@devui-design/icons/icomoon/devui-icon.css' + +export default defineComponent({ + name: 'DTransferDrag', + components: { + DTransfeCheckbox + }, + props: transferDragProps, + setup(props: TransferDragProps) { + /** data start **/ + const dragRef = ref(null) + const dragHighlight = ref(false) + const dragOverNodeKey = ref('') + const dropPosition = ref(null) + const dragTimer = ref(null) + /** data end **/ + + /** watch start **/ + /** watch end **/ + + /** methods start **/ + /** + * calcDropPosition: 根据event计算位置 + * event: event对象 + */ + const calcDropPosition = (event): number => { + const { clientY } = event; + const { top, bottom, height } = dragRef.value.getBoundingClientRect(); + const des = Math.max(height * 0.25, 2); + + if (clientY <= top + des) { + return -1; + } + if (clientY >= bottom - des) { + return 1; + } + return 0; + } + /** + * resetState: 重置属性 + */ + const resetState = () => { + dragOverNodeKey.value = '' + dropPosition.value = null + dragHighlight.value = null + } + /** methods end **/ + + /** 生命周期 start **/ + onUnmounted(() => { + clearTimeout(dragTimer.value) + }) + /** 生命周期 end **/ + + return () => { + const state = dragOverNodeKey.value === props.itemData.key + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + clearTimeout(dragTimer.value) + const curDropPosition = calcDropPosition(event) + if (props.itemData.key === dragOverNodeKey.value && curDropPosition === 0) { + resetState() + return + } + dragTimer.value = setTimeout(() => { + dragOverNodeKey.value = props.itemData.key + dropPosition.value = curDropPosition + }, 0); + props.onDragenter && props.onDragenter(event, props.itemData) + }} + onDragover={event => { + event.preventDefault() + event.stopPropagation() + if (props.itemData.key === dragOverNodeKey.value) { + const curDropPosition = calcDropPosition(event) + if (curDropPosition === dropPosition.value) { + return + } + dropPosition.value = curDropPosition; + } + props.onDragover && props.onDragover(event, props.itemData) + }} + onDragleave={event => { + event.stopPropagation() + resetState() + props.onDragleave && props.onDragleave(event, props.itemData) + }} + onDrop={event => { + event.preventDefault() + event.stopPropagation() + resetState() + props.onDrop && props.onDrop(event, props.itemData) + }} + onDragend={event => { + event.stopPropagation() + props.onDragend && props.onDragend(event, props.itemData) + }}> +
{ + event.stopPropagation() + dragHighlight.value = true + props.onDragstart && props.onDragstart(event, props.itemData) + }} + > + + + + + +
+
+ ) + } + } +}) \ No newline at end of file diff --git a/packages/devui-vue/devui/transfer/src/transfer.scss b/packages/devui-vue/devui/transfer/src/transfer.scss index 8a6bb867a2..6132d23d1a 100644 --- a/packages/devui-vue/devui/transfer/src/transfer.scss +++ b/packages/devui-vue/devui/transfer/src/transfer.scss @@ -4,7 +4,7 @@ $devui-transfer-border-color: #adb0b8; $devui-transfer-border-radius: 2px; $devui-transfer-header-height: 40px; $devui-transfer-header-border-line-color:#dfe1e6; -$devui-transfer-body-list-item-height: 36px; +$devui-transfer-body-list-item-height: 32px; .devui-transfer { display: flex; @@ -52,13 +52,14 @@ $devui-transfer-body-list-item-height: 36px; &-list { overflow: auto; width: 100%; - padding: 0 20px; &-item { height: $devui-transfer-body-list-item-height; line-height: $devui-transfer-body-list-item-height; - width: min-content; - max-width: 100%; + display: flex; + align-items: center; + border-top: 2px solid transparent; + border-bottom: 2px solid transparent; label { text-overflow: ellipsis; @@ -68,14 +69,27 @@ $devui-transfer-body-list-item-height: 36px; } } + &-drag { + width: 100%; + display: flex; + align-items: center; + + &__icon { + padding: 0 4px; + visibility: hidden; + height: 28px; + display: flex; + align-items: center; + } + } + + &-drag:hover .devui-transfer-panel-body-list-drag__icon { + visibility: visible; + } + &-tooltip { - // display: flex; - // max-width: calc(100% - 20px); - // overflow: hidden; - // text-overflow: ellipsis; - // white-space: nowrap; .slotElement { - max-width: 100%; + display: flex; } } @@ -87,6 +101,10 @@ $devui-transfer-body-list-item-height: 36px; color: $devui-disabled-text; } } + + &-nodrag { + padding: 0 20px; + } } &-operation { @@ -114,4 +132,20 @@ $devui-transfer-body-list-item-height: 36px; } } } + + &-drag-dragging { + background-color: $devui-brand-foil; + } + + &-drag-over { + background-color: $devui-brand-hover; + } +} + +.devui-transfer–drag-over-top { + border-top-color: $devui-brand-active; +} + +.devui-transfer–drag-over-bottom { + border-bottom-color: $devui-brand-active; } diff --git a/packages/devui-vue/devui/transfer/src/transfer.tsx b/packages/devui-vue/devui/transfer/src/transfer.tsx index e857d12b82..9fcf636bc0 100644 --- a/packages/devui-vue/devui/transfer/src/transfer.tsx +++ b/packages/devui-vue/devui/transfer/src/transfer.tsx @@ -1,5 +1,5 @@ import { defineComponent, reactive, watch, ref, SetupContext } from 'vue' -import { TState } from '../types' +import { TState, IItem } from '../types' import DTransferBase from './transfer-base' import DTransferOperation from './transfer-operation' import { initState } from '../common/use-transfer-base' @@ -39,7 +39,7 @@ export default defineComponent({ watch( () => leftOptions.keyword, - (nVal: string): void => { + (): void => { searchFilterData(leftOptions) } ) @@ -57,7 +57,7 @@ export default defineComponent({ watch( () => rightOptions.keyword, - (nVal: string): void => { + (): void => { searchFilterData(rightOptions) }, ) @@ -155,6 +155,14 @@ export default defineComponent({ const isFunction = (type: string): boolean => { return props[type] && typeof props[type] === 'function' } + + const dataSort = (target: TState, dragItem: IItem, dropItem: IItem, direction: string) => { + const startIndex = target.filterData.findIndex(item => item.key === dragItem.key) + const endIndex = target.filterData.findIndex(item => item.key === dropItem.key) + target.filterData.splice(endIndex, 1, dragItem) + target.filterData.splice(startIndex, 1, dropItem) + props.onDragend && props.onDragend(direction, dragItem, dropItem) + } /** methods end **/ return () => { @@ -172,6 +180,7 @@ export default defineComponent({ allCount={leftOptions.data.length} showTooltip={props.showTooltip} tooltipPosition={props.tooltipPosition} + isSourceDroppable={props.isSourceDroppable} v-slots={ { header: headerSlot(ctx, 'left'), @@ -181,6 +190,7 @@ export default defineComponent({ onChangeAllSource={(value) => changeAllSource(leftOptions, value)} onUpdateCheckeds={updateLeftCheckeds} onChangeQuery={(value) => changeQueryHandle(leftOptions, 'left', value)} + onDragend={(dragItem, dropItem) => dataSort(leftOptions, dragItem, dropItem, 'left')} /> changeAllSource(rightOptions, value)} onUpdateCheckeds={updateRightCheckeds} onChangeQuery={(value) => changeQueryHandle(rightOptions, 'right', value)} + onDragend={(dragItem, dropItem) => dataSort(rightOptions, dragItem, dropItem, 'right')} />
} diff --git a/packages/devui-vue/devui/tree-select/hooks/use-clear.ts b/packages/devui-vue/devui/tree-select/hooks/use-clear.ts index 0891d489f4..f81d37dfb8 100644 --- a/packages/devui-vue/devui/tree-select/hooks/use-clear.ts +++ b/packages/devui-vue/devui/tree-select/hooks/use-clear.ts @@ -5,22 +5,36 @@ import { TreeSelectProps } from '../src/tree-select-types' export default function useClear(props: TreeSelectProps, ctx: SetupContext, data: Ref): any { const isClearable = computed(() => { - return !props.disabled && props.allowClear && data.value.length > 0; + return !props.disabled && props.allowClear; }) - const handleClear = (e: MouseEvent) => { + const handleClearAll = (e: MouseEvent) => { e.preventDefault() e.stopPropagation() if (props.multiple) { ctx.emit('update:modelValue', []) + data.value = [] } else { ctx.emit('update:modelValue', '') data.value = '' } } + const handleClearItem = (e: MouseEvent, item?: string) => { + e.preventDefault() + e.stopPropagation() + if (props.multiple) { + data.value.splice(data.value.indexOf(item), 1) + ctx.emit('update:modelValue', data.value) + } else { + ctx.emit('update:modelValue', []) + data.value = [] + } + } + return { isClearable, - handleClear, + handleClearAll, + handleClearItem } } \ No newline at end of file diff --git a/packages/devui-vue/devui/tree-select/hooks/use-select.ts b/packages/devui-vue/devui/tree-select/hooks/use-select.ts index 24d0370065..5acdb01efa 100644 --- a/packages/devui-vue/devui/tree-select/hooks/use-select.ts +++ b/packages/devui-vue/devui/tree-select/hooks/use-select.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { TreeSelectProps, TreeItem } from '../src/tree-select-types' export default function useSelect(props: TreeSelectProps): any { - const inputValue = ref('') + const inputValue = ref>([]) const selectedCache = new Set() const selectValue = (item: TreeItem) => { @@ -14,7 +14,7 @@ export default function useSelect(props: TreeSelectProps): any { useCache(item) searchUp(item) searchDown(item) - inputValue.value = [...selectedCache].toString() + inputValue.value = [...selectedCache] as string[] } } diff --git a/packages/devui-vue/devui/tree-select/src/tree-select-types.ts b/packages/devui-vue/devui/tree-select/src/tree-select-types.ts index 0809b4da2b..8aa6cf2ab6 100644 --- a/packages/devui-vue/devui/tree-select/src/tree-select-types.ts +++ b/packages/devui-vue/devui/tree-select/src/tree-select-types.ts @@ -52,6 +52,10 @@ export const treeSelectProps = { type: Boolean, default: false }, + enableLabelization: { + type: Boolean, + default: false + }, onToggleChange: { type: Function as PropType<(bool: boolean) => void>, default: undefined, diff --git a/packages/devui-vue/devui/tree-select/src/tree-select.scss b/packages/devui-vue/devui/tree-select/src/tree-select.scss index 85b3a7836f..1b02d78ccb 100644 --- a/packages/devui-vue/devui/tree-select/src/tree-select.scss +++ b/packages/devui-vue/devui/tree-select/src/tree-select.scss @@ -41,13 +41,31 @@ $tree-select-item-font-size: 16px; cursor: pointer; width: 100%; height: $tree-select-input-height; - padding: 4px $tree-select-input-height 4px 10px; + padding: 0 10px; color: $devui-text; - vertical-align: middle; border: 1px solid $devui-form-control-line; border-radius: $devui-border-radius; outline: none; background-color: $devui-base-bg; + overflow: auto; + + &:empty::before { + color: lightgrey; + content: attr(placeholder); + vertical-align: middle; + } +} + +.devui-tree-select-value { + display: inline-block; + height: 80%; + vertical-align: middle; +} + +.devui-tree-select-value-enableLabelization { + padding: 0 10px; + margin: 2px 10px 2px 0; + background-color: $devui-icon-fill; } .devui-tree-select-dropdown { @@ -87,11 +105,12 @@ $tree-select-item-font-size: 16px; .devui-tree-select-clearable { position: relative; + &:hover { .devui-tree-select-clear { display: inline-flex; } - + .devui-tree-select-arrow { display: none; } @@ -105,6 +124,7 @@ $tree-select-item-font-size: 16px; .devui-tree-select-clear, .devui-tree-select-arrow { position: absolute; + top: 0; right: 0; height: 100%; width: $tree-select-input-height; @@ -121,4 +141,3 @@ $tree-select-item-font-size: 16px; color: $devui-icon-fill-active; } } - diff --git a/packages/devui-vue/devui/tree-select/src/tree-select.tsx b/packages/devui-vue/devui/tree-select/src/tree-select.tsx index dfa35e0825..5c43c5b87f 100644 --- a/packages/devui-vue/devui/tree-select/src/tree-select.tsx +++ b/packages/devui-vue/devui/tree-select/src/tree-select.tsx @@ -1,41 +1,52 @@ import './tree-select.scss' -import { defineComponent, ref, Transition } from 'vue' +import { defineComponent, toRefs, Transition } from 'vue' import type { SetupContext } from 'vue' import { treeSelectProps, TreeSelectProps, TreeItem } from './tree-select-types' -import { attributeExtension, className } from './utils' +import { nodeMap, attributeExtension, className } from './utils' import useToggle from '../hooks/use-toggle' import useSelect from '../hooks/use-select' import useClear from '../hooks/use-clear' import IconOpen from '../assets/open.svg' import IconClose from '../assets/close.svg' import Checkbox from '../../checkbox/src/checkbox' +import ClickOutside from '../../shared/devui-directive/clickoutside' export default defineComponent({ name: 'DTreeSelect', + directives: { ClickOutside }, props: treeSelectProps, emits: ['toggleChange', 'valueChange', 'update:modelValue'], setup(props: TreeSelectProps, ctx: SetupContext) { - const { treeData, placeholder, disabled, multiple, leafOnly } = props + const { treeData, placeholder, disabled, multiple, leafOnly, enableLabelization } = toRefs(props) const { visible, selectToggle, treeToggle} = useToggle(props) const { inputValue, selectValue } = useSelect(props) - const { isClearable, handleClear} = useClear(props, ctx, inputValue) + const { isClearable, handleClearAll, handleClearItem} = useClear(props, ctx, inputValue) - const clickNode = (item: TreeItem)=> { - if(!leafOnly) { + const clickNode = (item: TreeItem) => { + if(!leafOnly.value) { selectValue(item) - !multiple && selectToggle(item) + !multiple.value && selectToggle(item) } else { if(!item.children) { selectValue(item) - !multiple && selectToggle(item) + !multiple.value && selectToggle(item) } } } + const deleteNode = (e: MouseEvent, item: string) => { + handleClearItem(e, item) + selectValue(nodeMap.get(item)) + } + const treeSelectCls = className('devui-tree-select', { 'devui-tree-select-open': visible.value, - 'devui-tree-select-disabled': disabled, + 'devui-tree-select-disabled': disabled.value, + }) + + const treeSelectInputItem = className('devui-tree-select-value', { + 'devui-tree-select-value-enableLabelization': enableLabelization.value }) const renderNode = (item) => ( @@ -50,7 +61,9 @@ export default defineComponent({ : treeToggle(e, item)} /> :{'\u00A0\u00A0\u00A0'} } - { multiple + {ctx.slots.default + ? ctx.slots.default({ item }) + : multiple.value ? item.halfchecked ? : @@ -74,19 +87,38 @@ export default defineComponent({ return () => { return ( -
+
visible.value = false}>
selectToggle()}> - - handleClear(e)} class="devui-tree-select-clear"> + /> */} +
+ { multiple.value + ? inputValue.value.map((item) => ( +
+ {item} + {enableLabelization.value + ? deleteNode(e, item)}/> + : ,} +
+ )) + : !Array.isArray(inputValue.value) &&
+ {inputValue.value} + {enableLabelization.value + && handleClearItem(e)}/>} +
+ } +
+ handleClearAll(e)} class="devui-tree-select-clear"> @@ -95,7 +127,7 @@ export default defineComponent({
-
    {renderTree(attributeExtension(treeData))}
+
    {renderTree(attributeExtension(treeData.value))}
diff --git a/packages/devui-vue/devui/tree-select/src/utils.ts b/packages/devui-vue/devui/tree-select/src/utils.ts index 30f3136a3c..ab4e3a4c2f 100644 --- a/packages/devui-vue/devui/tree-select/src/utils.ts +++ b/packages/devui-vue/devui/tree-select/src/utils.ts @@ -1,9 +1,12 @@ -import { TreeData } from '../src/tree-select-types' +import { TreeData, TreeItem } from '../src/tree-select-types' + +export const nodeMap = new Map() export function attributeExtension(data: TreeData): any { data.forEach((el) => { let level = 1 el.level = level + nodeMap.set(el.label, el) const nodeQueue = [] nodeQueue.push(el) while(nodeQueue.length !== 0) { @@ -12,6 +15,7 @@ export function attributeExtension(data: TreeData): any { node.children.forEach((el) => { el.level = level + 1 el.parent = node + nodeMap.set(el.label, el) nodeQueue.push(el) }) } diff --git a/packages/devui-vue/devui/tree/__tests__/tree.spec.ts b/packages/devui-vue/devui/tree/__tests__/tree.spec.ts index 427125f6d7..08590ffe06 100644 --- a/packages/devui-vue/devui/tree/__tests__/tree.spec.ts +++ b/packages/devui-vue/devui/tree/__tests__/tree.spec.ts @@ -169,19 +169,19 @@ describe('tree', () => { }) it('should expand and collapse correctly', async () => { - const firstNode: Element = wrapper.element.firstElementChild + const firstNode = wrapper.get('.devui-tree-node:first-child') // 初始状态,节点是展开的 - expect(firstNode.classList).toContain('devui-tree-node__open') - + expect(firstNode.classes()).toContain('devui-tree-node__open') + // 点击之后,节点收起 - await wrapper.find('.devui-tree-node').trigger('click') + await wrapper.get('.devui-tree-node__folder:first-child').trigger('click') await nextTick() - expect(firstNode.classList).not.toContain('devui-tree-node__open') + expect(firstNode.classes()).not.toContain('devui-tree-node__open') // 再次点击,节点展开 - await wrapper.find('.devui-tree-node').trigger('click') + await wrapper.get('.devui-tree-node__folder:first-child').trigger('click') await nextTick() - expect(firstNode.classList).toContain('devui-tree-node__open') + expect(firstNode.classes()).toContain('devui-tree-node__open') }) }) diff --git a/packages/devui-vue/devui/tree/src/composables/use-draggable.ts b/packages/devui-vue/devui/tree/src/composables/use-draggable.ts new file mode 100644 index 0000000000..0089d14e84 --- /dev/null +++ b/packages/devui-vue/devui/tree/src/composables/use-draggable.ts @@ -0,0 +1,174 @@ +import { reactive, ref, watch } from 'vue' +import type { Ref } from 'vue' +import { TreeItem, IDropType, Nullable } from '../tree-types' +import { cloneDeep } from 'lodash' + +const ACTIVE_NODE = 'devui-tree-node__content--value-wrapper' +interface DragState { + dropType?: 'prev' | 'next' | 'inner' + draggingNode?: Nullable +} + +export default function useDraggable( + draggable: boolean, + dropType: IDropType, + node: Ref>, + renderData: Ref, + data: Ref +): any { + const dragState = reactive({ + dropType: null, + draggingNode: null, + }) + const treeIdMapValue = ref({}) + watch( + () => renderData.value, + () => { + treeIdMapValue.value = renderData.value.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}) + }, + { deep: true, immediate: true } + ) + + const removeDraggingStyle = (target: Nullable) => { + target + .querySelector(`.${ACTIVE_NODE}`) + ?.classList.remove(...['prev', 'next', 'inner'].map((item) => `devui-drop-${item}`)) + } + + const checkIsParent = (childNodeId: number | string, parentNodeId: number | string) => { + const realParentId = treeIdMapValue.value[childNodeId].parentId + if (realParentId === parentNodeId) { + return true + } else if (realParentId !== undefined) { + return checkIsParent(realParentId, parentNodeId) + } else { + return false + } + } + const handlerDropData = (dragNodeId: string | number, dropNodeId: string | number, dropType?: string) => { + const cloneData = cloneDeep(data.value) + let nowDragNode + let nowDropNode + const findDragAndDropNode = (curr: TreeItem[]) => { + if (!Array.isArray(curr)) return + curr.every((item, index) => { + if (nowDragNode && nowDropNode) { + return false + } + if (item.id === dragNodeId) { + nowDragNode = { target: curr, index, item } + } else if (item.id === dropNodeId) { + nowDropNode = { target: curr, index, item } + } + if (!nowDragNode || !nowDropNode) { + findDragAndDropNode(item.children) + } + return true + }) + } + findDragAndDropNode(cloneData) + if (nowDragNode && nowDropNode && dropType) { + const cloneDrapNode = cloneDeep(nowDragNode.target[nowDragNode.index]) + if (dropType === 'prev') { + nowDropNode.target.splice(nowDropNode.index, 0, cloneDrapNode) + } else if (dropType === 'next') { + nowDropNode.target.splice(nowDropNode.index + 1, 0, cloneDrapNode) + } else if (dropType === 'inner') { + const children = nowDropNode.target[nowDropNode.index].children + if (Array.isArray(children)) { + children.unshift(cloneDrapNode) + } else { + nowDropNode.target[nowDropNode.index].children = [cloneDrapNode] + } + } + const targetIndex = nowDragNode.target.indexOf(nowDragNode.item) + if (targetIndex !== -1) { + nowDragNode.target.splice(targetIndex, 1) + } + + } + + return cloneData + } + const onDragstart = (event: DragEvent, treeNode: TreeItem) => { + dragState.draggingNode = >event.target + const data = { + type: 'tree-node', + nodeId: treeNode.id + } + event.dataTransfer.setData('Text', JSON.stringify(data)) + } + const onDragover = (event: DragEvent) => { + if (draggable) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + if (!node) { + return + } + const dropPrev = dropType.dropPrev + const dropNext = dropType.dropNext + const dropInner = dropType.dropInner + + let innerDropType + + const prevPercent = dropPrev ? (dropInner ? 0.25 : dropNext ? 0.45 : 1) : -1 + const nextPercent = dropNext ? (dropInner ? 0.75 : dropPrev ? 0.55 : 0) : 1 + const currentTarget = >event.currentTarget + const targetPosition = currentTarget.getBoundingClientRect() + const distance = event.clientY - targetPosition.top + + if (distance < targetPosition.height * prevPercent) { + innerDropType = 'prev' + } else if (distance > targetPosition.height * nextPercent) { + innerDropType = 'next' + } else if (dropInner) { + innerDropType = 'inner' + } else { + innerDropType = undefined + } + removeDraggingStyle(currentTarget) + if (innerDropType && innerDropType !== 'none') { + currentTarget.querySelector(`.${ACTIVE_NODE}`)?.classList.add(`devui-drop-${innerDropType}`) + } + dragState.dropType = innerDropType + } + } + const onDragleave = (event: DragEvent) => { + removeDraggingStyle(>event.currentTarget) + } + const onDrop = (event: DragEvent, dropNode: TreeItem) => { + removeDraggingStyle(>event.currentTarget) + if (!draggable) { + return + } + event.preventDefault() + const transferDataStr = event.dataTransfer.getData('Text') + if (transferDataStr) { + try { + const transferData = JSON.parse(transferDataStr) + if (typeof transferData === 'object' && transferData.type === 'tree-node') { + const dragNodeId = transferData.nodeId + const isParent = checkIsParent(dropNode.id, dragNodeId) + if (dragNodeId === dropNode.id || isParent) { + return + } + let result + if (dragState.dropType) { + result = handlerDropData(dragNodeId, dropNode.id, dragState.dropType) + } + data.value = result + } + } catch (e) { + console.error(e) + } + } + } + + return { + onDragstart, + onDragover, + onDragleave, + onDrop, + dragState + } +} diff --git a/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts b/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts index 705cf07809..ff7a18c7e0 100644 --- a/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts +++ b/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts @@ -1,13 +1,8 @@ -import { ref } from 'vue' +import { Ref, ref, watch } from 'vue' +import { TreeItem } from '../tree-types' -export default function useMergeNode(data: Array): any { - - - const mergeObject = ( - treeItem, - childName = 'children', - labelName = 'label' - ) => { +export default function useMergeNode(data: Ref): any { + const mergeObject = (treeItem, childName = 'children', labelName = 'label') => { const { [childName]: children, [labelName]: label } = treeItem if ( Array.isArray(children) && @@ -17,7 +12,7 @@ export default function useMergeNode(data: Array): any { ) { return mergeObject( Object.assign({}, children[0], { - [labelName]: `${label} \\ ${children[0][labelName]}`, + [labelName]: `${label} \\ ${children[0][labelName]}` }) ) } @@ -41,14 +36,20 @@ export default function useMergeNode(data: Array): any { } return Object.assign({}, currentObject, { [childName]: mergeNode(currentObject[childName], level + 1, childName, labelName), - level: level + 1, + level: level + 1 }) }) } - - const mergeData = ref(mergeNode(data)) + const mergeData = ref(mergeNode(data.value)) + watch( + () => data.value, + () => { + mergeData.value = mergeNode(data.value) + }, + { deep: true } + ) return { - mergeData, + mergeData } } diff --git a/packages/devui-vue/devui/tree/src/composables/use-operate.tsx b/packages/devui-vue/devui/tree/src/composables/use-operate.tsx index 801f4a1461..67b94de8e4 100644 --- a/packages/devui-vue/devui/tree/src/composables/use-operate.tsx +++ b/packages/devui-vue/devui/tree/src/composables/use-operate.tsx @@ -11,16 +11,16 @@ interface TypeOperator { handleDelete: () => void } interface TypeOperateIconReflect { - id: TypeID, + id: TypeID renderIcon: (data: TreeItem) => JSX.Element } interface TypeeditStatusReflect { [id: TypeID]: boolean } interface TypeReturnUseOperate { - editStatusReflect: Ref, - operateIconReflect: Ref>, - handleReflectIdToIcon: TypeHandleReflectIdToIcon, + editStatusReflect: Ref + operateIconReflect: Ref> + handleReflectIdToIcon: TypeHandleReflectIdToIcon } type TypeUseOperate = (treeData: Ref) => TypeReturnUseOperate type TypeHandleReflectIdToIcon = (id: TypeID, operate: TypeOperator) => void diff --git a/packages/devui-vue/devui/tree/src/tree-types.ts b/packages/devui-vue/devui/tree/src/tree-types.ts index 58d6d70d93..e8951ba8cf 100644 --- a/packages/devui-vue/devui/tree/src/tree-types.ts +++ b/packages/devui-vue/devui/tree/src/tree-types.ts @@ -12,6 +12,11 @@ export interface TreeItem { children?: TreeData [key: string]: any } +export interface IDropType { + dropPrev?: boolean + dropNext?: boolean + dropInner?: boolean +} export interface SelectType { [key: string]: 'none' | 'half' | 'select' } @@ -36,14 +41,24 @@ export const treeProps = { type: Boolean, default: false }, + draggable: { + type: Boolean, + default: false + }, checkableRelation: { type: String as () => CheckableRelationType, default: 'none', - } + }, + dropType: { + type: Object as PropType, + default: () => ({}), + }, } as const export type TreeProps = ExtractPropTypes +export type Nullable = null | T + export interface TreeRootType { ctx: SetupContext props: TreeProps diff --git a/packages/devui-vue/devui/tree/src/tree.scss b/packages/devui-vue/devui/tree/src/tree.scss index 2ffc16243d..bec4537fcb 100644 --- a/packages/devui-vue/devui/tree/src/tree.scss +++ b/packages/devui-vue/devui/tree/src/tree.scss @@ -13,12 +13,37 @@ $keyframe-blue: #5e7ce0; white-space: nowrap; } +.devui-tree-indicator { + height: 1px; + background-color: $devui-brand; + position: absolute; +} + .devui-tree-node { color: $devui-text-weak; line-height: 1.5; white-space: nowrap; position: relative; + .devui-drop { + &-draggable { + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + } + + &-inner { + color: $devui-brand; + } + + &-prev { + border-top: 1px solid $devui-brand; + } + + &-next { + border-bottom: 1px solid $devui-brand; + } + } + .devui-tree-node__content { display: inline-flex; align-items: center; @@ -150,6 +175,13 @@ $keyframe-blue: #5e7ce0; } } + &__indent { + display: inline-block; + width: 16px; + height: 16px; + margin-left: 8px; + } + .devui-loading-children { display: inline-block; vertical-align: middle; @@ -308,7 +340,3 @@ $keyframe-blue: #5e7ce0; cursor: not-allowed !important; background-color: transparent !important; } - -.devui-tree-node__content { - transition: color $devui-animation-duration-fast $devui-animation-ease-in-smooth, background-color $devui-animation-duration-fast $devui-animation-ease-in-smooth; -} diff --git a/packages/devui-vue/devui/tree/src/tree.tsx b/packages/devui-vue/devui/tree/src/tree.tsx index 7239b04a64..41924cd8f4 100644 --- a/packages/devui-vue/devui/tree/src/tree.tsx +++ b/packages/devui-vue/devui/tree/src/tree.tsx @@ -1,6 +1,6 @@ -import { defineComponent, reactive, toRefs, provide } from 'vue' +import { defineComponent, reactive, ref, toRefs, provide, unref } from 'vue' import type { SetupContext } from 'vue' -import { treeProps, TreeProps, TreeItem, TreeRootType } from './tree-types' +import { treeProps, TreeProps, TreeItem, TreeRootType, Nullable } from './tree-types' import { CHECK_CONFIG } from './config' import { preCheckTree, deleteNode, getId } from './util' import Loading from '../../loading/src/service' @@ -11,6 +11,7 @@ import useHighlightNode from './composables/use-highlight' import useChecked from './composables/use-checked' import useLazy from './composables/use-lazy' import useOperate from './composables/use-operate' +import useDraggable from './composables/use-draggable' import IconOpen from './assets/open.svg' import IconClose from './assets/close.svg' import NodeContent from './tree-node-content' @@ -21,20 +22,19 @@ export default defineComponent({ props: treeProps, emits: ['nodeSelected'], setup(props: TreeProps, ctx: SetupContext) { - const { data, checkable, checkableRelation: cbr } = toRefs(reactive({ ...props, data: preCheckTree(props.data) })) - const { mergeData } = useMergeNode(data.value) + const { data, checkable, draggable, dropType, checkableRelation: cbr } = toRefs(reactive({ ...props, data: preCheckTree(props.data) })) + const node = ref>(null) + const { mergeData } = useMergeNode(data) const { openedData, toggle } = useToggle(mergeData) const { nodeClassNameReflect, handleInitNodeClassNameReflect, handleClickOnNode } = useHighlightNode() const { lazyNodesReflect, handleInitLazyNodeReflect, getLazyData } = useLazy() const { selected, onNodeClick } = useChecked(cbr, ctx, data.value) const { editStatusReflect, operateIconReflect, handleReflectIdToIcon } = useOperate(data) - + const { onDragstart, onDragover, onDragleave, onDrop } = useDraggable(draggable.value, dropType.value, node, openedData, data); provide('treeRoot', { ctx, props }) - const Indent = () => { - return - } + const renderNode = (item: TreeItem) => { - const { id = '', label, disabled, open, isParent, level, children, addable, editable, deletable } = item + const { id = '', disabled, open, isParent, level, children, addable, editable, deletable } = item handleReflectIdToIcon( id, { @@ -102,11 +102,16 @@ export default defineComponent({ return toggle(target, item) } return ( - isParent || children && children.length - ? open - ? - : - : +
+ { + isParent || children && children.length + ? open + ? + : + : + } +
+ ) } const checkState = CHECK_CONFIG[selected.value[id] ?? 'none'] @@ -114,13 +119,18 @@ export default defineComponent({
onDragstart(event, item)} + onDragover={(event: DragEvent) => onDragover(event, item)} + onDragleave={(event: DragEvent) => onDragleave(event)} + onDrop={(event: DragEvent) => onDrop(event, item)} >
handleClickOnNode(id)} > -
- { renderFoldIcon(item) } + { renderFoldIcon(item) } +
{ checkable.value && onNodeClick(item)} disabled={disabled} {...checkState} /> } { operateIconReflect.value.find(({ id: d }) => id === d).renderIcon(item) } @@ -134,24 +144,9 @@ export default defineComponent({
) } - const renderTree = (tree) => { - return tree.map(item => { - if (!item.children) { - return renderNode(item) - } else { - return ( - <> - {renderNode(item)} - {renderTree(item.children)} - - ) - } - }) - } return () => { return (
- {/* { renderTree(data.value) } */} { openedData.value.map(item => renderNode(item)) }
) diff --git a/packages/devui-vue/devui/tree/src/util.ts b/packages/devui-vue/devui/tree/src/util.ts index 9b0917e3a3..c984e8167a 100644 --- a/packages/devui-vue/devui/tree/src/util.ts +++ b/packages/devui-vue/devui/tree/src/util.ts @@ -20,7 +20,6 @@ export const flatten = (tree: Array, key = 'children'): Array => { const getRandomId = (): string => (Math.random() * 10 ** 9).toString().slice(0,8) const preCheckNodeId = (d: TreeItem, postfixId?: string): TreeItem => { const randomStr = getRandomId() - console.info('randomStr: ', randomStr) return { ...d, id: postfixId ? `${postfixId}_${randomStr}` : randomStr } } export const getId = (id: string): string => { @@ -34,6 +33,9 @@ export const getId = (id: string): string => { export const preCheckTree = (ds: TreeData, postfixId?: string): TreeData => { return ds.map(d => { const dd = preCheckNodeId(d, postfixId) + if (!dd.parentId && postfixId) { + dd.parentId = postfixId + } return d.children ? { ...dd, children: preCheckTree(d.children, dd.id) diff --git a/packages/devui-vue/devui/upload/index.ts b/packages/devui-vue/devui/upload/index.ts index 227a3ba02f..c284f0c870 100644 --- a/packages/devui-vue/devui/upload/index.ts +++ b/packages/devui-vue/devui/upload/index.ts @@ -1,19 +1,16 @@ -import type { App } from 'vue' -import Upload from './src/upload' -import fileDropDirective from './src/file-drop-directive' +import type { App } from 'vue'; +import Upload from './src/upload'; +import fileDropDirective from './src/file-drop-directive'; +export * from './src/upload-types'; -Upload.install = function (app: App) { - app.directive('file-drop', fileDropDirective) - app.component(Upload.name, Upload) -} - -export { Upload } +export { Upload }; export default { title: 'Upload 上传', category: '数据录入', status: '100%', install(app: App): void { - app.use(Upload as any) - } -} + app.directive('file-drop', fileDropDirective); + app.component(Upload.name, Upload); + }, +}; diff --git a/packages/devui-vue/devui/upload/src/composables/use-select-files.ts b/packages/devui-vue/devui/upload/src/composables/use-select-files.ts new file mode 100644 index 0000000000..0f518c91a6 --- /dev/null +++ b/packages/devui-vue/devui/upload/src/composables/use-select-files.ts @@ -0,0 +1,109 @@ +import { ref } from 'vue'; +import { IFileOptions } from '../upload-types'; +import { getNotAllowedFileTypeMsg, getBeyondMaximalFileSizeMsg, getAllFilesBeyondMaximalFileSizeMsg } from '../i18n-upload'; + +export const useSelectFiles = () => { + const BEYOND_MAXIMAL_FILE_SIZE_MSG = ref(''); + const simulateClickEvent = (input) => { + const evt = document.createEvent('MouseEvents'); + evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + input.dispatchEvent(evt); + }; + const selectFiles = ({ multiple, accept, webkitdirectory }: IFileOptions): Promise => { + return new Promise((resolve) => { + const tempNode = document.getElementById('d-upload-temp'); + if (tempNode) { + document.body.removeChild(tempNode); + } + const input = document.createElement('input'); + + input.style.position = 'fixed'; + input.style.left = '-2000px'; + input.style.top = '-2000px'; + + input.setAttribute('id', 'd-upload-temp'); + input.setAttribute('type', 'file'); + if (multiple) { + input.setAttribute('multiple', ''); + } + if (accept) { + input.setAttribute('accept', accept); + } + + if (webkitdirectory) { + input.setAttribute('webkitdirectory', ''); + } + + input.addEventListener('change', (event) => { + resolve(Array.prototype.slice.call((event.target as HTMLInputElement).files)); + }); + document.body.appendChild(input); // Fix compatibility issue with Internet Explorer 11 + simulateClickEvent(input); + }); + }; + + const isAllowedFileType = (accept: string, file: File) => { + if (accept) { + const acceptArr = accept.split(','); + const baseMimeType = file.type.replace(/\/.*$/, ''); + return acceptArr.some((type: string) => { + const validType = type.trim(); + // suffix name (e.g. '.png,.xlsx') + if (validType.startsWith('.')) { + return ( + file.name.toLowerCase().indexOf(validType.toLowerCase(), file.name.toLowerCase().length - validType.toLowerCase().length) > -1 + ); + // mime type like 'image/*' + } else if (/\/\*$/.test(validType)) { + return baseMimeType === validType.replace(/\/.*$/, ''); + } + // mime type like 'text/plain,application/json' + return file.type === validType; + }); + } + return true; + }; + + const beyondMaximalSize = (fileSize, maximumSize) => { + if (maximumSize) { + return fileSize > 1024 * 1024 * maximumSize; + } + return false; + }; + + const _validateFiles = (file, accept, uploadOptions) => { + if (!isAllowedFileType(accept, file)) { + return { + checkError: true, + errorMsg: getNotAllowedFileTypeMsg((file).name, accept), + }; + } + if (uploadOptions && beyondMaximalSize((file).size, uploadOptions.maximumSize)) { + return { + checkError: true, + errorMsg: getBeyondMaximalFileSizeMsg((file).name, uploadOptions.maximumSize), + }; + } + return { checkError: false, errorMsg: undefined }; + }; + + const triggerSelectFiles = (fileOptions: IFileOptions) => { + const { multiple, accept, webkitdirectory } = fileOptions; + return selectFiles({ multiple, accept, webkitdirectory }); + }; + const triggerDropFiles = (files: File[]) => { + return Promise.resolve(files); + }; + const checkAllFilesSize = (fileSize, maximumSize) => { + if (beyondMaximalSize(fileSize, maximumSize)) { + BEYOND_MAXIMAL_FILE_SIZE_MSG.value = getAllFilesBeyondMaximalFileSizeMsg(maximumSize); + return { checkError: true, errorMsg: BEYOND_MAXIMAL_FILE_SIZE_MSG.value }; + } + }; + return { + triggerSelectFiles, + _validateFiles, + triggerDropFiles, + checkAllFilesSize, + }; +}; diff --git a/packages/devui-vue/devui/upload/src/composables/use-upload.ts b/packages/devui-vue/devui/upload/src/composables/use-upload.ts new file mode 100644 index 0000000000..1275d9621c --- /dev/null +++ b/packages/devui-vue/devui/upload/src/composables/use-upload.ts @@ -0,0 +1,134 @@ +import { ref } from 'vue'; +import { FileUploader } from '../file-uploader'; +import { UploadStatus } from '../upload-types'; + +export const useUpload = () => { + const fileUploaders = ref>([]); + const filesWithSameName = ref([]); + + const checkFileSame = (fileName) => { + let checkRel = true; + + for (let i = 0; i < fileUploaders.value.length; i++) { + if (fileName === fileUploaders.value[i].file.name) { + checkRel = false; + if (filesWithSameName.value.indexOf(fileName) === -1) { + filesWithSameName.value.push(fileName); + } + break; + } + } + return checkRel; + }; + + const addFile = (file, options) => { + if (options && options.checkSameName) { + if (checkFileSame(file.name)) { + fileUploaders.value.push(new FileUploader(file, options)); + } + } else { + fileUploaders.value.push(new FileUploader(file, options)); + } + }; + + const getFiles = () => { + return fileUploaders.value.map((fileUploader) => { + return fileUploader.file; + }); + }; + + const getFullFiles = () => { + return fileUploaders.value.map((fileUploader) => { + return fileUploader; + }); + }; + + const dealOneTimeUploadFiles = async (uploads) => { + if (!uploads || !uploads.length) { + return Promise.reject('no files'); + } + // 触发文件上传 + let finalUploads = []; + await uploads[0].send(uploads).finally( + () => + // 根据uploads[0]的上传状态为其他file设置状态 + (finalUploads = uploads.map((file) => { + file.status = uploads[0].status; + file.percentage = uploads[0].percentage; + return { file: file.file, response: uploads[0].response }; + })) + ); + + return finalUploads; + }; + + const upload = async ( + oneFile? + ): Promise< + | never + | { + file: File; + response: any; + }[] + > => { + let uploads: any[] = []; + if (oneFile) { + oneFile.percentage = 0; + const uploadedFile = await oneFile.send(); + uploads.push(uploadedFile); + } else { + const preFiles = fileUploaders.value.filter((fileUploader) => fileUploader.status === UploadStatus.preLoad); + const failedFiles = fileUploaders.value.filter((fileUploader) => fileUploader.status === UploadStatus.failed); + const uploadFiles = preFiles.length > 0 ? preFiles : failedFiles; + uploads = await Promise.all( + uploadFiles.map(async (fileUploader) => { + fileUploader.percentage = 0; + const uploadedFile = await fileUploader.send(); + return uploadedFile; + }) + ); + } + if (uploads.length > 0) { + return Promise.resolve(uploads); + } + + return Promise.reject('no files'); + }; + + const _oneTimeUpload = () => { + const uploads = fileUploaders.value.filter((fileUploader) => fileUploader.status !== UploadStatus.uploaded); + return dealOneTimeUploadFiles(uploads); + }; + + const deleteFile = (file) => { + const deleteUploadFile = fileUploaders.value.find((fileUploader) => fileUploader.file === file); + deleteUploadFile.cancel(); + fileUploaders.value = fileUploaders.value.filter((fileUploader) => { + return file !== fileUploader.file; + }); + }; + + const removeFiles = () => { + fileUploaders.value = []; + filesWithSameName.value = []; + }; + const getSameNameFiles = () => { + return filesWithSameName.value.join(); + }; + const resetSameNameFiles = () => { + filesWithSameName.value = []; + }; + + return { + fileUploaders, + getFiles, + addFile, + getFullFiles, + deleteFile, + upload, + removeFiles, + getSameNameFiles, + resetSameNameFiles, + _oneTimeUpload, + }; +}; diff --git a/packages/devui-vue/devui/upload/src/file-drop-directive.ts b/packages/devui-vue/devui/upload/src/file-drop-directive.ts index 86831e711d..14c1ba480b 100644 --- a/packages/devui-vue/devui/upload/src/file-drop-directive.ts +++ b/packages/devui-vue/devui/upload/src/file-drop-directive.ts @@ -1,84 +1,84 @@ interface BindingType { value: { - enableDrop?: boolean - isSingle?: boolean - onFileDrop?: (files: File[]) => void - onFileOver?: (event: any) => void - } + droppable?: boolean; + isSingle?: boolean; + onFileDrop?: (files: File[]) => void; + onFileOver?: (event: any) => void; + }; } const getTransfer = (event: any) => { - return event.dataTransfer ? event.dataTransfer : event.originalEvent?.dataTransfer -} + return event.dataTransfer ? event.dataTransfer : event.originalEvent?.dataTransfer; +}; const haveFiles = (types: any) => { if (!types) { - return false + return false; } if (types.indexOf) { - return types.indexOf('Files') !== -1 + return types.indexOf('Files') !== -1; } else if (types.contains) { - return types.contains('Files') + return types.contains('Files'); } else { - return false + return false; } -} +}; const preventAndStop = (event: any) => { - event.preventDefault() - event.stopPropagation() -} + event.preventDefault(); + event.stopPropagation(); +}; const onDragOver = (el: HTMLElement, binding: BindingType) => { - const { onFileOver } = binding.value + const { onFileOver } = binding.value; el.addEventListener('dragover', (event) => { - const transfer = getTransfer(event) + const transfer = getTransfer(event); if (!haveFiles(transfer.types)) { - return + return; } - preventAndStop(event) - onFileOver && onFileOver(true) - }) -} + preventAndStop(event); + onFileOver && onFileOver(true); + }); +}; const onDragLeave = (el: HTMLElement, binding: BindingType) => { - const { onFileOver } = binding.value + const { onFileOver } = binding.value; el.addEventListener('dragleave', (event) => { if (event.currentTarget === el) { - return + return; } - preventAndStop(event) - onFileOver && onFileOver(true) - }) -} + preventAndStop(event); + onFileOver && onFileOver(true); + }); +}; const onDrop = (el: HTMLElement, binding: BindingType) => { - const { onFileDrop, isSingle } = binding.value + const { onFileDrop, isSingle } = binding.value; el.addEventListener('drop', (event) => { - const transfer = getTransfer(event) + const transfer = getTransfer(event); if (!transfer) { - return + return; } - preventAndStop(event) + preventAndStop(event); if (isSingle) { - onFileDrop && onFileDrop([transfer.files[0]]) + onFileDrop && onFileDrop([transfer.files[0]]); } else { - onFileDrop && onFileDrop(Array.from(transfer.files)) + onFileDrop && onFileDrop(Array.from(transfer.files)); } - }) -} + }); +}; const fileDropDirective = { mounted: (el: HTMLElement, binding: BindingType): void => { - const { enableDrop } = binding.value - if (!enableDrop) { - return + const { droppable } = binding.value; + if (!droppable) { + return; } - onDragOver(el, binding) - onDragLeave(el, binding) - onDrop(el, binding) - } -} + onDragOver(el, binding); + onDragLeave(el, binding); + onDrop(el, binding); + }, +}; -export default fileDropDirective +export default fileDropDirective; diff --git a/packages/devui-vue/devui/upload/src/file-uploader.ts b/packages/devui-vue/devui/upload/src/file-uploader.ts index 1acef9d914..89072682b6 100755 --- a/packages/devui-vue/devui/upload/src/file-uploader.ts +++ b/packages/devui-vue/devui/upload/src/file-uploader.ts @@ -1,133 +1,109 @@ -import { IUploadOptions, UploadStatus } from './upload-types' +import { IUploadOptions, UploadStatus } from './upload-types'; export class FileUploader { - private xhr: XMLHttpRequest - public status: UploadStatus - public response: any - public percentage = 0 + private xhr: XMLHttpRequest; + public status: UploadStatus; + public response: any; + public percentage = 0; constructor(public file: File, public uploadOptions: IUploadOptions) { - this.file = file - this.uploadOptions = uploadOptions - this.status = UploadStatus.preLoad + this.file = file; + this.uploadOptions = uploadOptions; + this.status = UploadStatus.preLoad; } - send(uploadFiles?: FileUploader[]): Promise<{ file: File; response: any; }> { + send(uploadFiles?: FileUploader[]): Promise<{ file: File; response: any }> { return new Promise((resolve, reject) => { - const { - uri, - method, - headers, - authToken, - authTokenHeader, - additionalParameter, - fileFieldName, - withCredentials, - responseType, - } = this.uploadOptions - const authTokenHeader_ = authTokenHeader || 'Authorization' - const fileFieldName_ = fileFieldName || 'file' - - this.xhr = new XMLHttpRequest() - this.xhr.open(method || 'POST', uri) + const { uri, method, headers, authToken, authTokenHeader, additionalParameter, fileFieldName, withCredentials, responseType } = + this.uploadOptions; + const authTokenHeader_ = authTokenHeader || 'Authorization'; + const fileFieldName_ = fileFieldName || 'file'; + + this.xhr = new XMLHttpRequest(); + this.xhr.open(method || 'POST', uri); if (withCredentials) { - this.xhr.withCredentials = withCredentials + this.xhr.withCredentials = withCredentials; } if (responseType) { - this.xhr.responseType = responseType + this.xhr.responseType = responseType; } if (authToken) { - this.xhr.setRequestHeader(authTokenHeader_, authToken) + this.xhr.setRequestHeader(authTokenHeader_, authToken); } if (headers) { Object.keys(headers).forEach((key) => { - this.xhr.setRequestHeader(key, headers[key]) - }) + this.xhr.setRequestHeader(key, headers[key]); + }); } this.xhr.upload.onprogress = (e) => { - this.percentage = Math.round((e.loaded * 100) / e.total) - } + this.percentage = Math.round((e.loaded * 100) / e.total); + }; const formData = uploadFiles && uploadFiles.length - ? this.oneTimeUploadFiles( - fileFieldName_, - additionalParameter, - uploadFiles - ) - : this.parallelUploadFiles(fileFieldName_, additionalParameter) + ? this.oneTimeUploadFiles(fileFieldName_, additionalParameter, uploadFiles) + : this.parallelUploadFiles(fileFieldName_, additionalParameter); - this.xhr.send(formData) - this.status = UploadStatus.uploading + this.xhr.send(formData); + this.status = UploadStatus.uploading; this.xhr.onabort = () => { - this.status = UploadStatus.preLoad - this.xhr = null - } + this.status = UploadStatus.preLoad; + this.xhr = null; + }; this.xhr.onerror = () => { - this.response = this.xhr.response - this.status = UploadStatus.failed - reject({ file: this.file, response: this.xhr.response }) - } + this.response = this.xhr.response; + this.status = UploadStatus.failed; + reject({ file: this.file, response: this.xhr.response }); + }; this.xhr.onload = () => { - if ( - this.xhr.readyState === 4 && - this.xhr.status >= 200 && - this.xhr.status < 300 - ) { - this.response = this.xhr.response - this.status = UploadStatus.uploaded - resolve({ file: this.file, response: this.xhr.response }) + if (this.xhr.readyState === 4 && this.xhr.status >= 200 && this.xhr.status < 300) { + this.response = this.xhr.response; + this.status = UploadStatus.uploaded; + resolve({ file: this.file, response: this.xhr.response }); } else { - this.response = this.xhr.response - this.status = UploadStatus.failed - reject({ file: this.file, response: this.xhr.response }) + this.response = this.xhr.response; + this.status = UploadStatus.failed; + reject({ file: this.file, response: this.xhr.response }); } - } - }) + }; + }); } - parallelUploadFiles( - fileFieldName_: string, - additionalParameter: Record - ): FormData { - const formData = new FormData() - formData.append(fileFieldName_, this.file, this.file.name) + parallelUploadFiles(fileFieldName_: string, additionalParameter: Record): FormData { + const formData = new FormData(); + formData.append(fileFieldName_, this.file, this.file.name); if (additionalParameter) { Object.keys(additionalParameter).forEach((key: string) => { - formData.append(key, additionalParameter[key]) - }) + formData.append(key, additionalParameter[key]); + }); } - return formData + return formData; } - oneTimeUploadFiles( - fileFieldName_: string, - additionalParameter: Record, - uploadFiles: FileUploader[] - ): FormData { - const formData = new FormData() + oneTimeUploadFiles(fileFieldName_: string, additionalParameter: Record, uploadFiles: FileUploader[]): FormData { + const formData = new FormData(); uploadFiles.forEach((element) => { - formData.append(fileFieldName_, element.file, element.file.name) + formData.append(fileFieldName_, element.file, element.file.name); if (additionalParameter) { Object.keys(additionalParameter).forEach((key: string) => { - formData.append(key, additionalParameter[key]) - }) + formData.append(key, additionalParameter[key]); + }); } - }) - return formData + }); + return formData; } cancel(): void { if (this.xhr) { - this.xhr.abort() + this.xhr.abort(); } } } diff --git a/packages/devui-vue/devui/upload/src/i18n-upload.ts b/packages/devui-vue/devui/upload/src/i18n-upload.ts index b2cbbc3820..9e458a1a56 100644 --- a/packages/devui-vue/devui/upload/src/i18n-upload.ts +++ b/packages/devui-vue/devui/upload/src/i18n-upload.ts @@ -11,32 +11,19 @@ export const i18nText = { delete: '删除', reUpload: '重新上传', cancelUpload: '取消上传', -} +}; -export const getFailedFilesCount = (failedCount: number): string => - `${failedCount}个文件上传失败!` -export const getUploadingFilesCount = ( - uploadingCount: number, - filesCount: number -): string => `${uploadingCount}/${filesCount}正在上传` -export const getSelectedFilesCount = (filesCount: number): string => - `已添加${filesCount}个文件` -export const getAllFilesBeyondMaximalFileSizeMsg = ( - maximalSize: number -): string => - `最大支持上传${maximalSize}MB的文件, 您本次上传的所有文件超过可上传文件大小` -export const getBeyondMaximalFileSizeMsg = ( - filename: string, - maximalSize: number -): string => { - return `最大支持上传${maximalSize}MB的文件, 您上传的文件"${filename}"超过可上传文件大小` -} -export const getNotAllowedFileTypeMsg = ( - filename: string, - scope: string -): string => { - return `支持的文件类型: "${scope}", 您上传的文件"${filename}"不在允许范围内,请重新选择文件` -} +export const getFailedFilesCount = (failedCount: number): string => `${failedCount}个文件上传失败!`; +export const getUploadingFilesCount = (uploadingCount: number, filesCount: number): string => `${uploadingCount}/${filesCount}正在上传`; +export const getSelectedFilesCount = (filesCount: number): string => `已添加${filesCount}个文件`; +export const getAllFilesBeyondMaximalFileSizeMsg = (maximalSize: number): string => + `最大支持上传${maximalSize}MB的文件, 您本次上传的所有文件超过可上传文件大小`; +export const getBeyondMaximalFileSizeMsg = (filename: string, maximalSize: number): string => { + return `最大支持上传${maximalSize}MB的文件, 您上传的文件"${filename}"超过可上传文件大小`; +}; +export const getNotAllowedFileTypeMsg = (filename: string, scope: string): string => { + return `支持的文件类型: "${scope}", 您上传的文件"${filename}"不在允许范围内,请重新选择文件`; +}; export const getExistSameNameFilesMsg = (sameNames: string): string => { - return `您上传的 "${sameNames}" 存在重名文件, 请重新选择文件` -} + return `您上传的 "${sameNames}" 存在重名文件, 请重新选择文件`; +}; diff --git a/packages/devui-vue/devui/upload/src/single-upload.tsx b/packages/devui-vue/devui/upload/src/single-upload.tsx deleted file mode 100644 index ff98a5c460..0000000000 --- a/packages/devui-vue/devui/upload/src/single-upload.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { defineComponent, toRefs, computed, ref } from 'vue' -import { ToastService } from '../../toast' -import { uploadProps, UploadProps, UploadStatus } from './upload-types' -import { useUpload } from './use-upload' -import { useSelectFiles } from './use-select-files' -import { i18nText } from './i18n-upload' -import './upload.scss' - -export default defineComponent({ - name: 'DSingleUpload', - props: uploadProps, - emits: [ - 'fileDrop', - 'fileOver', - 'fileSelect', - 'successEvent', - 'errorEvent', - 'deleteUploadedFileEvent', - 'update:uploadedFiles' - ], - setup(props: UploadProps, ctx) { - const { - uploadOptions, - fileOptions, - placeholderText, - autoUpload, - withoutBtn, - uploadText, - disabled, - beforeUpload, - enableDrop, - showTip, - uploadedFiles - } = toRefs(props) - const isDropOVer = ref(false) - const { getFiles, fileUploaders, addFile, getFullFiles, deleteFile, upload, removeFiles } = - useUpload() - const { triggerSelectFiles, _validateFiles, triggerDropFiles } = useSelectFiles() - const filename = computed(() => (getFiles()[0] || {}).name || '') - - const alertMsg = (errorMsg: string) => { - ToastService.open({ - value: [{ severity: 'warn', content: errorMsg }] - }) - } - - const canUpload = () => { - let uploadResult = Promise.resolve(true) - if (beforeUpload.value) { - const result: any = beforeUpload.value( - (getFullFiles()[0] as unknown as File) || ({} as File) - ) - if (typeof result !== 'undefined') { - if (result.then) { - uploadResult = result - } else { - uploadResult = Promise.resolve(result) - } - } - } - return uploadResult - } - - const fileUpload = () => { - canUpload().then((canUpload) => { - if (!canUpload) { - return - } - upload() - .then((results: { file: File; response: any; }[]) => { - ctx.emit('successEvent', results) - const newFiles = results.map((result) => result.file) - const newUploadedFiles = [...newFiles, ...uploadedFiles.value] - ctx.emit('update:uploadedFiles', newUploadedFiles) - }) - .catch((error) => { - console.error(error) - if (fileUploaders.value[0]) { - fileUploaders.value[0].percentage = 0 - } - ctx.emit('errorEvent', error) - }) - }) - } - - const checkValid = () => { - fileUploaders.value.forEach((fileUploader) => { - const checkResult = _validateFiles( - fileUploader.file, - fileOptions.value.accept, - fileUploader.uploadOptions - ) - if (checkResult.checkError) { - deleteFile(fileUploader.file) - alertMsg(checkResult.errorMsg) - } - }) - } - - const _dealFiles = (promise: Promise) => { - promise - .then((files) => { - files.forEach((file) => { - // 单文件上传前先清空数组 - removeFiles() - addFile(file, uploadOptions.value) - }) - checkValid() - const file = fileUploaders.value[0]?.file - if (props.onChange) { - props.onChange(file) - } - if (file) { - ctx.emit('fileSelect', file) - } - if (autoUpload.value) { - fileUpload() - } - }) - .catch((error: Error) => { - alertMsg(error.message) - }) - } - - const handleClick = () => { - if ( - disabled.value || - (fileUploaders.value[0] && fileUploaders.value[0]?.status === UploadStatus.uploading) - ) { - return - } - _dealFiles(triggerSelectFiles(fileOptions.value)) - } - - const onDeleteFile = (event: Event) => { - event.stopPropagation() - const files = getFiles() - deleteFile(files[0]) - } - // 删除已上传文件 - const deleteUploadedFile = (file: File) => { - const newUploadedFiles = uploadedFiles.value.filter((uploadedFile) => { - return uploadedFile.name !== file.name - }) - ctx.emit('deleteUploadedFileEvent', file) - ctx.emit('update:uploadedFiles', newUploadedFiles) - } - const onFileDrop = (files: File[]) => { - isDropOVer.value = false - _dealFiles(triggerDropFiles(files)) - ctx.emit('fileDrop', files[0]) - } - const onFileOver = (event: boolean) => { - isDropOVer.value = event - ctx.emit('fileOver', event) - } - return { - placeholderText, - filename, - autoUpload, - withoutBtn, - fileUploaders, - uploadText, - handleClick, - onDeleteFile, - fileUpload, - enableDrop, - onFileDrop, - onFileOver, - isDropOVer, - showTip, - uploadedFiles, - deleteUploadedFile - } - }, - render() { - const { - placeholderText, - filename, - autoUpload, - withoutBtn, - fileUploaders, - uploadText, - handleClick, - onDeleteFile, - fileUpload, - enableDrop, - onFileDrop, - onFileOver, - isDropOVer, - disabled, - showTip, - uploadedFiles, - deleteUploadedFile - } = this - return ( -
-
- {this.$slots.default?.() ? ( -
{this.$slots.default()}
- ) : ( -
-
- {!filename && ( -
{placeholderText}
- )} - {!!filename && ( -
- - {filename} - - onDeleteFile(event)} - /> - {fileUploaders[0]?.status === UploadStatus.uploading && ( -
- -
- )} - {fileUploaders[0].status === UploadStatus.failed && ( - - )} - {fileUploaders[0].status === UploadStatus.uploaded && ( - - )} -
- )} -
- - - -
- )} - {!autoUpload && !withoutBtn && ( - - {(!fileUploaders[0] || !fileUploaders[0]?.status) && {uploadText}} - {fileUploaders[0]?.status === UploadStatus.uploading && 上传中...} - {fileUploaders[0]?.status === UploadStatus.uploaded && 已上传} - {fileUploaders[0]?.status === UploadStatus.failed && 上传失败} - - )} -
- {showTip && ( -
- {fileUploaders[0]?.status === UploadStatus.uploading && ( - {i18nText.uploading} - )} - {fileUploaders[0]?.status === UploadStatus.uploaded && ( -
- - {i18nText.uploadSuccess} -
- )} - {fileUploaders[0]?.status === UploadStatus.failed && ( -
- - - {i18nText.uploadFailed} - {i18nText.reUpload} - -
- )} -
- )} -
- {this.$slots.preloadFiles?.({ - fileUploaders, - deleteFile: onDeleteFile - })} -
-
- {this.$slots.uploadedFiles?.({ - uploadedFiles, - deleteFile: deleteUploadedFile - })} -
-
- ) - } -}) diff --git a/packages/devui-vue/devui/upload/src/upload-types.ts b/packages/devui-vue/devui/upload/src/upload-types.ts index d9e564926d..5a4226aaff 100644 --- a/packages/devui-vue/devui/upload/src/upload-types.ts +++ b/packages/devui-vue/devui/upload/src/upload-types.ts @@ -1,133 +1,127 @@ -import type { PropType, ExtractPropTypes } from 'vue' -import { FileUploader } from './file-uploader' +import type { PropType, ExtractPropTypes } from 'vue'; +import { FileUploader } from './file-uploader'; + export class IUploadOptions { // 上传接口地址 - uri: string + uri: string; // http 请求方法 - method?: string + method?: string; // 上传文件大小限制 - maximumSize?: number + maximumSize?: number; // 自定义请求headers headers?: { - [key: string]: any - } + [key: string]: any; + }; // 认证token - authToken?: string + authToken?: string; // 认证token header标示 - authTokenHeader?: string + authTokenHeader?: string; // 上传额外自定义参数 additionalParameter?: { - [key: string]: any - } + [key: string]: any; + }; // 上传文件字段名称,默认file - fileFieldName?: string + fileFieldName?: string; // 多文件上传,是否检查文件重名,设置为true,重名文件不会覆盖,否则会覆盖上传 - checkSameName?: boolean + checkSameName?: boolean; // 指示了是否该使用类似cookies,authorization headers(头部授权)或者TLS客户端证书这一类资格证书来创建一个跨站点访问控制(cross-site Access-Control)请求 - withCredentials?: boolean + withCredentials?: boolean; // 手动设置返回数据类型 - responseType?: 'arraybuffer' | 'blob' | 'json' | 'text' + responseType?: 'arraybuffer' | 'blob' | 'json' | 'text'; } export class IFileOptions { - accept?: string - multiple?: boolean - webkitdirectory: boolean + accept?: string; + multiple?: boolean; + webkitdirectory: boolean; } export enum UploadStatus { preLoad = 0, uploading, uploaded, - failed + failed, } -type DynamicUploadOptionsFn = (files, uploadOptions) => IUploadOptions -type ChangeFn = (_: any) => void -type BeforeUploadFn = (file: FileUploader) => boolean | Promise +type DynamicUploadOptionsFn = (files, uploadOptions) => IUploadOptions; +type ChangeFn = (_: any) => void; +type BeforeUploadFn = (file: FileUploader) => boolean | Promise; + export const uploadProps = { // 规定能够通过文件上传进行提交的文件类型,例如 accept: '.xls,.xlsx,.pages,.mp3,.png' accept: { - type: String + type: String, }, // 是否允许用户选择文件目录,而不是文件 webkitdirectory: { type: Boolean, - default: false + default: false, }, uploadOptions: { type: Object as PropType, - required: true }, multiple: { type: Boolean, - default: false + default: false, }, autoUpload: { type: Boolean, - default: false - }, - placeholderText: { - type: String, - default: '选择文件' + default: true, }, - uploadText: { + placeholder: { type: String, - default: '上传' + default: '选择文件', }, - uploadedFiles: { + modelValue: { type: Array as PropType, - default: () => [] + default: () => [], }, - withoutBtn: { + droppable: { type: Boolean, - default: false - }, - enableDrop: { - type: Boolean, - default: false + default: false, }, beforeUpload: { - type: Function as PropType + type: Function as PropType, }, /** @deprecated */ dynamicUploadOptionsFn: { - type: Function as PropType + type: Function as PropType, }, disabled: { type: Boolean, - default: false + default: false, }, onChange: { - type: Function as PropType + type: Function as PropType, }, fileDrop: { type: Function as PropType<(v: any) => void>, - default: undefined + default: undefined, }, fileOver: { type: Function as PropType<(v: boolean) => void>, - default: undefined + default: undefined, }, fileSelect: { type: Function as PropType<(v: File) => void>, - default: undefined + default: undefined, }, deleteUploadedFile: { type: Function as PropType<(v: string) => void>, - default: undefined + default: undefined, }, 'on-error': { - type: Function as PropType<(v: { file: File; response: any; }) => void>, - default: undefined + type: Function as PropType<(v: { file: File; response: any }) => void>, + default: undefined, }, 'on-success': { - type: Function as PropType<(v: { file: File; response: any; }[]) => void>, - default: undefined + type: Function as PropType<(v: { file: File; response: any }[]) => void>, + default: undefined, }, oneTimeUpload: { type: Boolean, - default: false - } -} as const -export type UploadProps = ExtractPropTypes + default: false, + }, +} as const; + +export type UploadProps = ExtractPropTypes; diff --git a/packages/devui-vue/devui/upload/src/upload.scss b/packages/devui-vue/devui/upload/src/upload.scss index 287179cd86..54f1657528 100644 --- a/packages/devui-vue/devui/upload/src/upload.scss +++ b/packages/devui-vue/devui/upload/src/upload.scss @@ -7,10 +7,10 @@ .devui-input-group { position: relative; - display: flex; + display: flex !important; align-items: center; border-collapse: separate; - width: 100%; + width: 360px; } .devui-input-group:not(.disabled):hover .devui-input-group-addon { @@ -42,11 +42,8 @@ border-bottom: 1px solid $devui-form-control-line; border-right: 1px solid $devui-form-control-line; border-radius: 0 $devui-border-radius $devui-border-radius 0; - transition: - border-color $devui-animation-duration-slow - $devui-animation-ease-in-out-smooth, - background-color $devui-animation-duration-slow - $devui-animation-ease-in-out-smooth; + transition: border-color $devui-animation-duration-slow $devui-animation-ease-in-out-smooth, + background-color $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; cursor: pointer; height: 100%; position: relative; @@ -68,11 +65,8 @@ background-image: none; border: 1px solid $devui-form-control-line; border-radius: $devui-border-radius 0 0 $devui-border-radius; - transition: - border-color $devui-animation-duration-slow - $devui-animation-ease-in-out-smooth, - box-shadow $devui-animation-duration-slow - $devui-animation-ease-in-out-smooth; + transition: border-color $devui-animation-duration-slow $devui-animation-ease-in-out-smooth, + box-shadow $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; &.devui-files-list { max-height: 52px; diff --git a/packages/devui-vue/devui/upload/src/upload.tsx b/packages/devui-vue/devui/upload/src/upload.tsx index 8678147cf3..2e50fcc528 100644 --- a/packages/devui-vue/devui/upload/src/upload.tsx +++ b/packages/devui-vue/devui/upload/src/upload.tsx @@ -1,320 +1,241 @@ -import { defineComponent, toRefs, ref } from 'vue' -import { ToastService } from '../../toast' -import { UploadStatus, UploadProps, uploadProps } from './upload-types' -import { useSelectFiles } from './use-select-files' -import { useUpload } from './use-upload' -import { - getFailedFilesCount, - getSelectedFilesCount, - getUploadingFilesCount, - getExistSameNameFilesMsg -} from './i18n-upload' -import { FileUploader } from './file-uploader' -import './upload.scss' +import { defineComponent, toRefs, ref } from 'vue'; +import { NotificationService } from '../../notification'; +import { UploadStatus, UploadProps, uploadProps } from './upload-types'; +import { useSelectFiles } from './composables/use-select-files'; +import { useUpload } from './composables/use-upload'; +import { getFailedFilesCount, getSelectedFilesCount, getUploadingFilesCount, getExistSameNameFilesMsg } from './i18n-upload'; +import { FileUploader } from './file-uploader'; +import './upload.scss'; export default defineComponent({ name: 'DUpload', props: uploadProps, - emits: ['fileDrop', 'fileOver', 'fileSelect', 'deleteUploadedFile', 'update:uploadedFiles'], + emits: ['fileDrop', 'fileOver', 'fileSelect', 'deleteUploadedFile', 'update:modelValue'], setup(props: UploadProps, ctx) { const { uploadOptions, - placeholderText, + placeholder, autoUpload, - withoutBtn, - uploadText, disabled, beforeUpload, - enableDrop, + droppable, oneTimeUpload, - uploadedFiles, + modelValue, multiple, accept, - webkitdirectory - } = toRefs(props) - const { triggerSelectFiles, _validateFiles, triggerDropFiles, checkAllFilesSize } = - useSelectFiles() - const { - fileUploaders, - addFile, - getFullFiles, - deleteFile, - upload, - resetSameNameFiles, - removeFiles, - _oneTimeUpload, - getSameNameFiles - } = useUpload() - const isDropOVer = ref(false) - const uploadTips = ref('') + webkitdirectory, + } = toRefs(props); + const { triggerSelectFiles, _validateFiles, triggerDropFiles, checkAllFilesSize } = useSelectFiles(); + const { fileUploaders, addFile, getFullFiles, deleteFile, upload, resetSameNameFiles, removeFiles, _oneTimeUpload, getSameNameFiles } = + useUpload(); + const isDropOVer = ref(false); + const uploadTips = ref(''); const alertMsg = (errorMsg: string) => { - ToastService.open({ - value: [{ severity: 'warn', content: errorMsg }] - }) - } + NotificationService.open({ + type: 'warning', + content: errorMsg, + }); + }; const checkValid = () => { - let totalFileSize = 0 + let totalFileSize = 0; fileUploaders.value.forEach((fileUploader) => { - totalFileSize += fileUploader.file.size + totalFileSize += fileUploader.file.size; - const checkResult = _validateFiles( - fileUploader.file, - accept.value, - fileUploader.uploadOptions - ) + const checkResult = _validateFiles(fileUploader.file, accept.value, fileUploader.uploadOptions); if (checkResult && checkResult.checkError) { - deleteFile(fileUploader.file) - alertMsg(checkResult.errorMsg) - return + deleteFile(fileUploader.file); + alertMsg(checkResult.errorMsg); + return; } - }) + }); if (oneTimeUpload.value) { - const checkResult = checkAllFilesSize(totalFileSize, uploadOptions.value.maximumSize) + const checkResult = checkAllFilesSize(totalFileSize, uploadOptions.value.maximumSize); if (checkResult && checkResult.checkError) { - removeFiles() - alertMsg(checkResult.errorMsg) + removeFiles(); + alertMsg(checkResult.errorMsg); } } - } + }; const _dealFiles = (promise: Promise) => { - resetSameNameFiles() + resetSameNameFiles(); promise .then((files) => { files.forEach((file) => { // 单文件上传前先清空数组 if (!multiple.value) { - removeFiles() + removeFiles(); } - addFile(file, uploadOptions.value) - // debounceTime(100) - }) - checkValid() - const sameNameFiles = getSameNameFiles() - if (uploadOptions.value.checkSameName && sameNameFiles.length) { - alertMsg(getExistSameNameFilesMsg(sameNameFiles)) + addFile(file, uploadOptions.value); + }); + checkValid(); + const sameNameFiles = getSameNameFiles(); + if (uploadOptions.value && uploadOptions.value.checkSameName && sameNameFiles.length) { + alertMsg(getExistSameNameFilesMsg(sameNameFiles)); } const selectedFiles = fileUploaders.value .filter((fileUploader) => fileUploader.status === UploadStatus.preLoad) - .map((fileUploader) => fileUploader.file) - ctx.emit('fileSelect', selectedFiles) + .map((fileUploader) => fileUploader.file); + ctx.emit('fileSelect', selectedFiles); if (autoUpload.value) { - fileUpload() + fileUpload(); } }) .catch((error: Error) => { - alertMsg(error.message) - }) - } + alertMsg(error.message); + }); + }; const handleClick = () => { if (disabled.value) { - return + return; } _dealFiles( triggerSelectFiles({ accept: accept.value, multiple: multiple.value, - webkitdirectory: webkitdirectory.value + webkitdirectory: webkitdirectory.value, }) - ) - } + ); + }; const onFileDrop = (files: File[]) => { - isDropOVer.value = false - _dealFiles(triggerDropFiles(files)) - ctx.emit('fileDrop', files) - } + isDropOVer.value = false; + _dealFiles(triggerDropFiles(files)); + ctx.emit('fileDrop', files); + }; const onFileOver = (event: boolean) => { - isDropOVer.value = event - ctx.emit('fileOver', event) - } + isDropOVer.value = event; + ctx.emit('fileOver', event); + }; // 删除已上传文件 const deleteUploadedFile = (file: File) => { - const newUploadedFiles = uploadedFiles.value.filter((uploadedFile) => { - return uploadedFile.name !== file.name - }) - ctx.emit('deleteUploadedFile', file) - ctx.emit('update:uploadedFiles', newUploadedFiles) - } + const newUploadedFiles = modelValue.value.filter((uploadedFile) => { + return uploadedFile.name !== file.name; + }); + ctx.emit('deleteUploadedFile', file); + ctx.emit('update:modelValue', newUploadedFiles); + }; const onDeleteFile = (event: Event, file: File, status: UploadStatus) => { - event.stopPropagation() + event.stopPropagation(); if (status === UploadStatus.uploaded) { - deleteUploadedFile(file) + deleteUploadedFile(file); } - deleteFile(file) - } + deleteFile(file); + }; const canUpload = () => { - let uploadResult = Promise.resolve(true) + let uploadResult = Promise.resolve(true); if (beforeUpload.value) { - const result: any = beforeUpload.value(getFullFiles()) + const result: any = beforeUpload.value(getFullFiles()); if (typeof result !== 'undefined') { if (result.then) { - uploadResult = result + uploadResult = result; } else { - uploadResult = Promise.resolve(result) + uploadResult = Promise.resolve(result); } } } - return uploadResult - } - const fileUpload = (event: Event, fileUploader?: FileUploader) => { + return uploadResult; + }; + const fileUpload = (event?: Event, fileUploader?: FileUploader) => { if (event) { - event.stopPropagation() + event.stopPropagation(); } canUpload().then((_canUpload) => { if (!_canUpload) { - removeFiles() - return + removeFiles(); + return; } - const uploadObservable = oneTimeUpload.value ? _oneTimeUpload() : upload(fileUploader) + const uploadObservable = oneTimeUpload.value ? _oneTimeUpload() : upload(fileUploader); uploadObservable - .then((results: Array<{ file: File; response: any; }>) => { - props['on-success'] && props['on-success'](results) - const newFiles = results.map((result) => result.file) - const newUploadedFiles = [...newFiles, ...uploadedFiles.value] - ctx.emit('update:uploadedFiles', newUploadedFiles) + .then((results: Array<{ file: File; response: any }>) => { + props['on-success'] && props['on-success'](results); + const newFiles = results.map((result) => result.file); + const newUploadedFiles = [...newFiles, ...modelValue.value]; + ctx.emit('update:modelValue', newUploadedFiles); }) .catch((error) => { - props['on-error'] && props['on-error'](error) - }) - }) - } + props['on-error'] && props['on-error'](error); + }); + }); + }; const getStatus = () => { - let uploadingCount = 0 - let uploadedCount = 0 - let failedCount = 0 - const filesCount = fileUploaders.value.length + let uploadingCount = 0; + let uploadedCount = 0; + let failedCount = 0; + const filesCount = fileUploaders.value.length; fileUploaders.value.forEach((fileUploader) => { if (fileUploader.status === UploadStatus.uploading) { - uploadingCount++ + uploadingCount++; } else if (fileUploader.status === UploadStatus.uploaded) { - uploadedCount++ + uploadedCount++; } else if (fileUploader.status === UploadStatus.failed) { - failedCount++ + failedCount++; } - }) + }); if (failedCount > 0) { - uploadTips.value = getFailedFilesCount(failedCount) - return 'failed' + uploadTips.value = getFailedFilesCount(failedCount); + return 'failed'; } if (uploadingCount > 0) { - uploadTips.value = getUploadingFilesCount(uploadingCount, filesCount) - return 'uploading' + uploadTips.value = getUploadingFilesCount(uploadingCount, filesCount); + return 'uploading'; } if (uploadedCount === filesCount && uploadedCount !== 0) { - return 'uploaded' + return 'uploaded'; } if (filesCount !== 0) { - uploadTips.value = getSelectedFilesCount(filesCount) - return 'selected' + uploadTips.value = getSelectedFilesCount(filesCount); + return 'selected'; } - } + }; // 取消上传 const cancelUpload = () => { fileUploaders.value = fileUploaders.value.map((fileUploader) => { if (fileUploader.status === UploadStatus.uploading) { // 取消上传请求 - fileUploader.cancel() - fileUploader.status = UploadStatus.failed + fileUploader.cancel(); + fileUploader.status = UploadStatus.failed; } - return fileUploader - }) - } + return fileUploader; + }); + }; - return { - uploadOptions, - placeholderText, - autoUpload, - withoutBtn, - uploadText, - disabled, - beforeUpload, - enableDrop, - isDropOVer, - onFileDrop, - onFileOver, - handleClick, - fileUploaders, - onDeleteFile, - fileUpload, - getStatus, - uploadTips, - cancelUpload, - deleteUploadedFile, - multiple - } - }, - render() { - const { - placeholderText, - autoUpload, - withoutBtn, - uploadText, - disabled, - enableDrop, - isDropOVer, - onFileDrop, - onFileOver, - handleClick, - fileUploaders, - onDeleteFile, - fileUpload, - uploadedFiles, - deleteUploadedFile, - multiple - } = this - - return ( + return () => (
- {this.$slots.default?.() ? ( -
{this.$slots.default()}
+ v-file-drop={{ droppable, isSingle: !multiple, onFileDrop, onFileOver }} + style={`border: ${isDropOVer.value ? '1px solid #15bf15' : '0'}`}> + {ctx.slots.default?.() ? ( +
{ctx.slots.default()}
) : ( -
- {fileUploaders.length === 0 && ( -
{placeholderText}
- )} - {fileUploaders.length > 0 && ( +
+ {fileUploaders.value.length === 0 &&
{placeholder.value}
} + {fileUploaders.value.length > 0 && (
    - {fileUploaders.map((fileUploader, index) => ( + {fileUploaders.value.map((fileUploader, index) => (
  • - + title={fileUploader.file.name}> + {fileUploader.file.name} - onDeleteFile(event, fileUploader.file, fileUploader.status) - } + onClick={(event) => onDeleteFile(event, fileUploader.file, fileUploader.status)} /> {fileUploader.status === UploadStatus.uploading && (
    @@ -323,16 +244,11 @@ export default defineComponent({ percentage={fileUploader.percentage} barbgcolor='#50D4AB' strokeWidth={8} - showContent={false} - > + showContent={false}>
    )} - {fileUploader.status === UploadStatus.failed && ( - - )} - {fileUploader.status === UploadStatus.uploaded && ( - - )} + {fileUploader.status === UploadStatus.failed && } + {fileUploader.status === UploadStatus.uploaded && }
  • ))}
@@ -342,31 +258,14 @@ export default defineComponent({
)} - {!autoUpload && !withoutBtn && ( - - {uploadText} - - )}
- {}
- {this.$slots.preloadFiles?.({ - fileUploaders, - deleteFile: onDeleteFile - })} -
-
- {this.$slots.uploadedFiles?.({ - uploadedFiles, - deleteFile: deleteUploadedFile + {ctx.slots['uploaded-files']?.({ + uploadedFiles: modelValue.value, + deleteFile: deleteUploadedFile, })}
- ) - } -}) + ); + }, +}); diff --git a/packages/devui-vue/devui/upload/src/use-select-files.ts b/packages/devui-vue/devui/upload/src/use-select-files.ts deleted file mode 100644 index 83e5bd353e..0000000000 --- a/packages/devui-vue/devui/upload/src/use-select-files.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { ref } from 'vue' -import { IFileOptions } from './upload-types' -import { - getNotAllowedFileTypeMsg, - getBeyondMaximalFileSizeMsg, - getAllFilesBeyondMaximalFileSizeMsg -} from './i18n-upload' - -export const useSelectFiles = () => { - const BEYOND_MAXIMAL_FILE_SIZE_MSG = ref('') - const simulateClickEvent = (input) => { - const evt = document.createEvent('MouseEvents') - evt.initMouseEvent( - 'click', - true, - true, - window, - 1, - 0, - 0, - 0, - 0, - false, - false, - false, - false, - 0, - null - ) - input.dispatchEvent(evt) - } - const selectFiles = ({ multiple, accept, webkitdirectory }: IFileOptions): Promise => { - return new Promise((resolve) => { - const tempNode = document.getElementById('d-upload-temp') - if (tempNode) { - document.body.removeChild(tempNode) - } - const input = document.createElement('input') - - input.style.position = 'fixed' - input.style.left = '-2000px' - input.style.top = '-2000px' - - input.setAttribute('id', 'd-upload-temp') - input.setAttribute('type', 'file') - if (multiple) { - input.setAttribute('multiple', '') - } - if (accept) { - input.setAttribute('accept', accept) - } - - if (webkitdirectory) { - input.setAttribute('webkitdirectory', '') - } - - input.addEventListener('change', (event) => { - resolve(Array.prototype.slice.call((event.target as HTMLInputElement).files)) - }) - document.body.appendChild(input) // Fix compatibility issue with Internet Explorer 11 - simulateClickEvent(input) - }) - } - - const isAllowedFileType = (accept: string, file: File) => { - if (accept) { - const acceptArr = accept.split(',') - const baseMimeType = file.type.replace(/\/.*$/, '') - return acceptArr.some((type: string) => { - const validType = type.trim() - // suffix name (e.g. '.png,.xlsx') - if (validType.startsWith('.')) { - return ( - file.name - .toLowerCase() - .indexOf( - validType.toLowerCase(), - file.name.toLowerCase().length - validType.toLowerCase().length - ) > -1 - ) - // mime type like 'image/*' - } else if (/\/\*$/.test(validType)) { - return baseMimeType === validType.replace(/\/.*$/, '') - } - // mime type like 'text/plain,application/json' - return file.type === validType - }) - } - return true - } - - const beyondMaximalSize = (fileSize, maximumSize) => { - if (maximumSize) { - return fileSize > 1024 * 1024 * maximumSize - } - return false - } - - const _validateFiles = (file, accept, uploadOptions) => { - if (!isAllowedFileType(accept, file)) { - return { - checkError: true, - errorMsg: getNotAllowedFileTypeMsg((file).name, accept) - } - } - if (uploadOptions && beyondMaximalSize((file).size, uploadOptions.maximumSize)) { - return { - checkError: true, - errorMsg: getBeyondMaximalFileSizeMsg((file).name, uploadOptions.maximumSize) - } - } - return { checkError: false, errorMsg: undefined } - } - - const triggerSelectFiles = (fileOptions: IFileOptions) => { - const { multiple, accept, webkitdirectory } = fileOptions - return selectFiles({ multiple, accept, webkitdirectory }) - } - const triggerDropFiles = (files: File[]) => { - return Promise.resolve(files) - } - const checkAllFilesSize = (fileSize, maximumSize) => { - if (beyondMaximalSize(fileSize, maximumSize)) { - BEYOND_MAXIMAL_FILE_SIZE_MSG.value = getAllFilesBeyondMaximalFileSizeMsg(maximumSize) - return { checkError: true, errorMsg: BEYOND_MAXIMAL_FILE_SIZE_MSG.value } - } - } - return { - triggerSelectFiles, - _validateFiles, - triggerDropFiles, - checkAllFilesSize - } -} diff --git a/packages/devui-vue/devui/upload/src/use-upload.ts b/packages/devui-vue/devui/upload/src/use-upload.ts deleted file mode 100644 index 72aa7a48d6..0000000000 --- a/packages/devui-vue/devui/upload/src/use-upload.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { ref } from 'vue' -import { FileUploader } from './file-uploader' -import { UploadStatus } from './upload-types' - -export const useUpload = () => { - const fileUploaders = ref>([]) - const filesWithSameName = ref([]) - - const checkFileSame = (fileName) => { - let checkRel = true - - for (let i = 0; i < fileUploaders.value.length; i++) { - if (fileName === fileUploaders.value[i].file.name) { - checkRel = false - if (filesWithSameName.value.indexOf(fileName) === -1) { - filesWithSameName.value.push(fileName) - } - break - } - } - return checkRel - } - - const addFile = (file, options) => { - if (options && options.checkSameName) { - if (checkFileSame(file.name)) { - fileUploaders.value.push(new FileUploader(file, options)) - } - } else { - fileUploaders.value.push(new FileUploader(file, options)) - } - } - - const getFiles = () => { - return fileUploaders.value.map((fileUploader) => { - return fileUploader.file - }) - } - - const getFullFiles = () => { - return fileUploaders.value.map((fileUploader) => { - return fileUploader - }) - } - - const dealOneTimeUploadFiles = async (uploads) => { - if (!uploads || !uploads.length) { - return Promise.reject('no files') - } - // 触发文件上传 - let finalUploads = [] - await uploads[0].send(uploads).finally( - () => - // 根据uploads[0]的上传状态为其他file设置状态 - (finalUploads = uploads.map((file) => { - file.status = uploads[0].status - file.percentage = uploads[0].percentage - return { file: file.file, response: uploads[0].response } - })) - ) - - return finalUploads - } - - const upload = async ( - oneFile? - ): Promise< - | never - | { - file: File - response: any - }[] - > => { - let uploads: any[] = [] - if (oneFile) { - oneFile.percentage = 0 - const uploadedFile = await oneFile.send() - uploads.push(uploadedFile) - } else { - const preFiles = fileUploaders.value.filter( - (fileUploader) => fileUploader.status === UploadStatus.preLoad - ) - const failedFiles = fileUploaders.value.filter( - (fileUploader) => fileUploader.status === UploadStatus.failed - ) - const uploadFiles = preFiles.length > 0 ? preFiles : failedFiles - uploads = await Promise.all( - uploadFiles.map(async (fileUploader) => { - fileUploader.percentage = 0 - const uploadedFile = await fileUploader.send() - return uploadedFile - }) - ) - } - if (uploads.length > 0) { - return Promise.resolve(uploads) - } - - return Promise.reject('no files') - } - - const _oneTimeUpload = () => { - const uploads = fileUploaders.value.filter( - (fileUploader) => fileUploader.status !== UploadStatus.uploaded - ) - return dealOneTimeUploadFiles(uploads) - } - - const deleteFile = (file) => { - const deleteUploadFile = fileUploaders.value.find( - (fileUploader) => fileUploader.file === file - ) - deleteUploadFile.cancel() - fileUploaders.value = fileUploaders.value.filter((fileUploader) => { - return file !== fileUploader.file - }) - } - - const removeFiles = () => { - fileUploaders.value = [] - filesWithSameName.value = [] - } - const getSameNameFiles = () => { - return filesWithSameName.value.join() - } - const resetSameNameFiles = () => { - filesWithSameName.value = [] - } - - return { - fileUploaders, - getFiles, - addFile, - getFullFiles, - deleteFile, - upload, - removeFiles, - getSameNameFiles, - resetSameNameFiles, - _oneTimeUpload, - } -} diff --git a/packages/devui-vue/docs/.vitepress/config/markdown.ts b/packages/devui-vue/docs/.vitepress/config/markdown.ts index 2f33761224..6f1cc86e9e 100644 --- a/packages/devui-vue/docs/.vitepress/config/markdown.ts +++ b/packages/devui-vue/docs/.vitepress/config/markdown.ts @@ -1,7 +1,9 @@ const markdown = { config: (md) => { const { demoBlockPlugin } = require('vitepress-theme-demoblock') - md.use(demoBlockPlugin) + md.use(demoBlockPlugin, { + cssPreprocessor: 'scss' + }) } } export default markdown diff --git a/packages/devui-vue/docs/.vitepress/config/nav.ts b/packages/devui-vue/docs/.vitepress/config/nav.ts index 5453cb438a..ae5c12a10c 100644 --- a/packages/devui-vue/docs/.vitepress/config/nav.ts +++ b/packages/devui-vue/docs/.vitepress/config/nav.ts @@ -1,7 +1,9 @@ const nav = [ { text: '组件', link: '/' }, - { text: '版本历程', link: 'https://github.com/DevCloudFE/vue-devui/releases' }, - { text: '设计规范', link: 'https://devui.design/design-cn/start' }, + { text: '贡献指南', link: '/CONTRIBUTING' }, + { text: 'Playground', link: 'https://brenner8023.github.io/devui-playground' }, + { text: '更新日志', link: 'https://github.com/DevCloudFE/vue-devui/releases' }, + { text: '设计规范', link: 'https://devui.design/design-cn/start' } ] -export default nav \ No newline at end of file +export default nav diff --git a/packages/devui-vue/docs/.vitepress/devui-theme/Layout.vue b/packages/devui-vue/docs/.vitepress/devui-theme/Layout.vue index f7a99d8232..eda91a10c0 100644 --- a/packages/devui-vue/docs/.vitepress/devui-theme/Layout.vue +++ b/packages/devui-vue/docs/.vitepress/devui-theme/Layout.vue @@ -1,5 +1,5 @@ @@ -63,8 +72,7 @@ defineEmits(['toggle'])
- - +
@@ -93,9 +101,6 @@ defineEmits(['toggle']) padding: 0.7rem 1.5rem 0.7rem 4rem; height: var(--header-height); background-color: $devui-base-bg; - &:hover { - cursor: pointer; - } } @media (min-width: 720px) { @@ -123,6 +128,8 @@ defineEmits(['toggle']) } .custom-nav-item:hover { + cursor: pointer; + svg, path { fill: $devui-brand; diff --git a/packages/devui-vue/docs/.vitepress/devui-theme/components/NavBarTitle.vue b/packages/devui-vue/docs/.vitepress/devui-theme/components/NavBarTitle.vue index 9a29e7752d..f469c4ee19 100644 --- a/packages/devui-vue/docs/.vitepress/devui-theme/components/NavBarTitle.vue +++ b/packages/devui-vue/docs/.vitepress/devui-theme/components/NavBarTitle.vue @@ -32,6 +32,7 @@ function gopage() { .nav-bar-title:hover { text-decoration: none; + cursor: pointer; } .logo { diff --git a/packages/devui-vue/docs/.vitepress/devui-theme/components/SideBarLink.js b/packages/devui-vue/docs/.vitepress/devui-theme/components/SideBarLink.js index 69251f0596..30b032bdc9 100644 --- a/packages/devui-vue/docs/.vitepress/devui-theme/components/SideBarLink.js +++ b/packages/devui-vue/docs/.vitepress/devui-theme/components/SideBarLink.js @@ -45,11 +45,15 @@ export const SideBarLink = (props) => { class: { 'sidebar-link-item': true, active }, href: link }, [ - status && h('span', { + (status && import.meta.env.DEV) && h('span', { class: 'sidebar-link-status', style: `background-color: ${dotColor}` }), - text, + h('span', { + class: 'sidebar-link-text' + }, [ + text + ]) ]), childItems ]); @@ -67,7 +71,8 @@ function resolveLink(base, path) { function createChildren(active, children, headers, depth = 1) { if (children && children.length > 0) { return h('ul', { class: 'sidebar-links' }, children.map((c) => { - return h(SideBarLink, { item: c, depth }); + const showSidebarItem = import.meta.env.DEV || (import.meta.env.PROD && c.status === '100%'); + return showSidebarItem && h(SideBarLink, { item: c, depth }); })); } return active && headers diff --git a/packages/devui-vue/docs/.vitepress/devui-theme/components/icons/Theme.vue b/packages/devui-vue/docs/.vitepress/devui-theme/components/icons/Theme.vue new file mode 100644 index 0000000000..b7455e44b5 --- /dev/null +++ b/packages/devui-vue/docs/.vitepress/devui-theme/components/icons/Theme.vue @@ -0,0 +1,25 @@ + \ No newline at end of file diff --git a/packages/devui-vue/docs/.vitepress/devui-theme/styles/layout.scss b/packages/devui-vue/docs/.vitepress/devui-theme/styles/layout.scss index 7a81ed5a02..addaa060bc 100644 --- a/packages/devui-vue/docs/.vitepress/devui-theme/styles/layout.scss +++ b/packages/devui-vue/docs/.vitepress/devui-theme/styles/layout.scss @@ -173,7 +173,8 @@ li > ol { } table { - display: block; + display: table; + width: 100%; border-collapse: collapse; margin: 1rem 0; overflow-x: auto; @@ -268,3 +269,22 @@ $max-width: 1440px; } } +[ui-theme=infinity-theme], [ui-theme=galaxy-theme] { + .nav-bar { + background-color: $devui-base-bg !important; + } +} + +[ui-theme=sweet-theme], [ui-theme=provence-theme], [ui-theme=deep-theme] { + .nav-bar { + background-color: $devui-brand !important; + + a { + color: #fff !important; + } + + svg, path, polygon { + fill: #fff !important; + } + } +} diff --git a/packages/devui-vue/docs/.vitepress/devui-theme/styles/sidebar-links.scss b/packages/devui-vue/docs/.vitepress/devui-theme/styles/sidebar-links.scss index 23afbb074f..d758290336 100644 --- a/packages/devui-vue/docs/.vitepress/devui-theme/styles/sidebar-links.scss +++ b/packages/devui-vue/docs/.vitepress/devui-theme/styles/sidebar-links.scss @@ -107,10 +107,18 @@ a.sidebar-link-item.active { font-weight: 400; } -.sidebar .sidebar-link-status { - display: inline-block; - width: 6px; - height: 6px; - margin-right: 8px; - border-radius: 50px; +.sidebar { + .sidebar-link-status { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 8px; + border-radius: 50px; + } + + .sidebar-link-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } } \ No newline at end of file diff --git a/packages/devui-vue/docs/CONTRIBUTING.md b/packages/devui-vue/docs/CONTRIBUTING.md new file mode 100644 index 0000000000..2d20ed6322 --- /dev/null +++ b/packages/devui-vue/docs/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Vue Devui 贡献指南 + +你好! 我们很高兴你有兴趣为 Vue Devui 做出贡献。 在提交你的贡献之前,请务必花点时间阅读以下指南: + +## 快速上手 + +Vue Devui 使用 `lerna.json` + `yarn` 构建 `monorepo` 仓库,你应该使用 [yarn](https://yarn.bootcss.com/) 包管理器,以确保不会因为包管理器的不同而引发异常 +如果你想参与 `devui-vue` 的开发或者测试: + +1. 点击Github右上角的Fork按钮,将仓库Fork仓库到个人空间 +2. Clone个人空间项目到本地:`git clone git@github.com:username/vue-devui.git` +3. 在 Vue Devui 的根目录下运行`yarn i`, 安装node依赖 +4. 进入 `packages/devui-vue` 目录下,运行 `yarn run dev`,这个脚本将会启动 `vitepress` 和组件库的开发环境下构建 +5. 使用浏览器访问:http://localhost:3000(如遇白屏,请手动刷新页面) + +```bash +# username 为用户名,执行前请替换 +git clone git@github.com:username/vue-devui.git +cd vue-devui +git remote add upstream git@github.com:DevCloudFE/vue-devui.git +yarn i +yarn run dev +# 或者(推荐) +cd packages/devui-vue && yarn run dev +``` + +## 参与贡献 + +Vue Devui 是一个多人合作的开源项目,为了避免多人同时开发同一个组件/功能,请先在 [issues 列表](https://github.com/DevCloudFE/vue-devui/issues) 中选择自己感兴趣的任务,在评论区认领 + +1. 请确保你已经完成快速上手中的步骤,并且正常访问 http://localhost:3000 +2. 创建新分支 `git checkout -b username/feature1`,分支名字建议为`username/feat-xxx`/`username/fix-xxx` +3. 本地编码 +4. 遵循 [Angular Commit Message Format](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit) 进行提交(**不符合规范的提交将不会被合并到dev分支**) +5. 提交到远程仓库,也就是Fork 后的仓库,`git push origin branchName` +6. (可选) 同步上游仓库dev分支最新代码,`git pull upstream dev` +7. 打开上游仓库提交[PR](https://github.com/DevCloudFE/vue-devui/pulls) +> 如果涉及新组件或组件的新特性,则需要: +9. 完善组件中英文文档 +10. 完善组件的单元测试 +11. 完成组件[自检清单](https://github.com/DevCloudFE/vue-devui/wiki/%E7%BB%84%E4%BB%B6%E8%87%AA%E6%A3%80%E6%B8%85%E5%8D%95) +12. 仓库管理员进行Code Review,并提出意见 +13. PR 发起人根据意见调整代码(一个分支发起了PR后,后续的commit会自动同步,不需要重新PR) +14. 仓库管理员合并PR +15. 贡献流程结束,感谢你的贡献 diff --git a/packages/devui-vue/docs/components/accordion/index.md b/packages/devui-vue/docs/components/accordion/index.md index a25613266c..1f26155926 100644 --- a/packages/devui-vue/docs/components/accordion/index.md +++ b/packages/devui-vue/docs/components/accordion/index.md @@ -1,114 +1,126 @@ # Accordion 手风琴 + 为页面提供导航的组件。 + ### 何时使用 + 需要通过分组组织菜单的时候使用。 ### 基本用法 -传入菜单,监听含子项的可展开菜单的开合事件(menuToggle)或可点击菜单的点击事件(itemClick)。可展开菜单默认展开使用属性open,可点击菜单默认激活使用属性active,禁用项使用disabled。通过restrictOneOpen设置是否限制只能展开一个一级菜单。 + +传入菜单,监听含子项的可展开菜单的开合事件(menuToggle)或可点击菜单的点击事件(itemClick)。可展开菜单默认展开使用属性 open,可点击菜单默认激活使用属性 active,禁用项使用 disabled。通过 restrictOneOpen 设置是否限制只能展开一个一级菜单。 :::demo ```vue ``` - ::: +### 使用内置路由和链接类型 -### 使用模板 -可展开菜单和可点击菜单分别使用模板。可展开菜单指定menuItemTemplate,可点击菜单指定itemTemplate。没有数据模板指定noContentTemplate,并可以通过showNoContent控制无数据的时候不展开。 加载中模板指定loadingTemplate,通过item的loadingKey对应的属性值控制是否显示加载中。 +通过设置 linkType 切换不同的内置路由和链接类型:默认类型'';路由类型'routerLink';外链类型:'hrefLink';基于数据判断路由或链接类型:'dependOnLinkTypeKey'。 :::demo +```vue + + + +``` + +::: + +### 使用模板 + +可展开菜单和可点击菜单分别使用模板。可展开菜单指定 menuItemTemplate,可点击菜单指定 itemTemplate。没有数据模板指定 noContentTemplate,并可以通过 showNoContent 控制无数据的时候不展开。 加载中模板指定 loadingTemplate,通过 item 的 loadingKey 对应的属性值控制是否显示加载中。 + +:::demo ```vue +``` + +::: + + +### 设置禁用 +通过 disabled 设置是否禁用。 +:::demo + +```vue + + + + + +``` + +::: + +### 自定义数据匹配方法 +通过 searchFn 自定义数据的匹配方法和返回的数据格式。 +:::demo + +```vue + + + + + +``` + +::: + +### 自定义模板展示 +通过 itemTemplate、noResultItemTemplate 自定义下拉框和无匹配提示。 +:::demo + +```vue + + + + + +``` + +::: + + +### 最近输入 + +通过 latestSource 设置最近输入。 + +:::demo + +```vue + + + + + +``` + +::: + + + +### 懒加载 +enableLazyLoad 开启懒加载 + +:::demo + +```vue + + + + + +``` + +::: + + +### d-auto-complete + +d-auto-complete 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | 全局配置项 | +| :--------------------: | :-------------------------------------------------: | :----------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------- | ---------- | +| source | `Array` | -- | 必选,有 searchFn 的情况下可以不必选 | [基本用法](#基本用法) | +| allowEmptyValueSearch | `boolean` | false | 可选,在绑定的输入框 value 为空时,是否进行搜索提示操作 | [基本用法](#基本用法) | +| appendToBody | `boolean` | false | 可选,下拉弹出是否 append to body | [基本用法](#基本用法) | +| appendToBodyDirections | `Object as PropType` | `{originX: 'left',originY: 'bottom',overlayX: 'left',overlayY: 'top',}` | 可选,指定下拉框与输入框的相对位置,ConnectionPosition 请参考 Overlay | [基本用法](#基本用法) | +| disabled | `boolean` | false | 可选,是否禁用指令 | [设置禁用](#设置禁用) | +| delay | `number` | 300 | 可选,只有在 delay 时间经过后并且未输入新值,才做搜索查询(`ms`) | [基本用法](#基本用法) | +| disabledKey | `string` | -- | 可选,禁用单个选项,当传入资源 source 选项类型为对象,比如设置为'disabled',则当对象的 disable 属性为 true 时,比如{ label: xxx, disabled: true },该选项将禁用 | [自定义数据匹配方法](#自定义数据匹配方法) | +| itemTemplate | `slot` | -- | 可选,自定义展示模板。slotProps:{ index: 下标索引, item: 当前项内容 }。 | [自定义模板展示](#自定义模板展示) | +| noResultItemTemplate | `slot` | -- | 可选,没有匹配项的展示结果。slotProps:输入内容。 | [自定义模板展示](#自定义模板展示) | +| formatter | `(item: any) => string` | [`defaultFormatter`](#defaultFormatter) | 可选,格式化函数 | [自定义数据匹配方法](#自定义数据匹配方法) | +| isSearching | `boolean` | false | 可选,是否在搜索中,用于控制 searchingTemplate 是否显示 | [自定义数据匹配方法](#自定义数据匹配方法) | +| searchingTemplate | `slot` | -- | 可选,自定义搜索中显示模板。slotProps:输入内容。 | [自定义数据匹配方法](#自定义数据匹配方法) | +| sceneType | `string` | -- | 可选,值为 'select'、'suggest' | [启用懒加载](#启用懒加载) | +| searchFn | `(term: string) => Array` | [`defaultSearchFn`](#defaultSearchFn) | 可选,自定义搜索过滤 | [自定义数据匹配方法](#自定义数据匹配方法) | +| tipsText | `string` | '最近输入' | 可选,提示文字 | [设置禁用](#设置禁用) | +| latestSource | `Array` | -- | 可选, 最近输入 | [最近输入](#最近输入) | +| valueParser | `(item: any) => any` | [`defaultValueParse`](#defaultValueParse) | 可选, 对选中后数据进行处理 | [懒加载](#懒加载) | +| enableLazyLoad | `boolean` | false | 可选,是否允许懒加载 | [懒加载](#懒加载) | +| dAutoCompleteWidth | `number` | -- | 可选,调整宽度(`px`) |[基本用法](#基本用法) +| showAnimation | `boolean` | true | 可选,是否开启动画 | | ✔ | | | + +d-auto-complete 事件 + +| 参数 | 类型 | 说明 | 跳转 Demo | +| :-----------------: | :----------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------- | +| loadMore | `EventEmitter>` | 懒加载触发事件,配合`enableLazyLoad`使用,使用`$event.loadFinish()`关闭 loading 状态,其中\$event 为 AutoCompletePopupComponent 的实例 | [懒加载](#懒加载) | +| selectValue | `EventEmitter` | 可选,选择选项之后的回调函数 | [基本用法](#基本用法) | +| transInputFocusEmit | `EventEmitter` | 可选,Input focus 时回调函数 | [基本用法](#基本用法) | + + +# 接口 & 类型定义 + +### defaultSearchFn + +```ts +defaultSearchFn = (term) => { + return source.forEach((item)=>{ + let cur = formatter(item) + cur = cur.toLowerCase() + if(cur.startsWith(term)){ + arr.push(item) + } + }) + }; +``` +term 为输入的关键字。 + + +### defaultFormatter + +```ts +defaultFormatter = (item) => (item ? item.label || item.toString() : ''); +``` +item 为数据项。 + + +### defaultValueParse + +```ts +defaultValueParse = (item) => item; +``` +item 为数据项。 diff --git a/packages/devui-vue/docs/components/avatar/index.md b/packages/devui-vue/docs/components/avatar/index.md index 013806da87..1148342faa 100644 --- a/packages/devui-vue/docs/components/avatar/index.md +++ b/packages/devui-vue/docs/components/avatar/index.md @@ -113,13 +113,17 @@ ### 头像显示基本规则 - `中文开头`:取传入字符串的最后两个字符 + - `英文开头`:取传入字符串的前面两个字符 + - `多个英文名连用`:取传入字符串的前两个英文名首字母 + - `非中英文开头`:取传入字符串的前两个字符 ### 头像特殊显示规则 - 未传入`name`,`customText`,`imgSrc`,视为使用该头像的用户不存在 + - 传入`name`,`customText`,`imgSrc`的值为空,视为使用该头像的用户无昵称,使用默认头像 ### 显示优先级排序 diff --git a/packages/devui-vue/docs/components/badge/index.md b/packages/devui-vue/docs/components/badge/index.md index 41f8007189..5597e7fe30 100644 --- a/packages/devui-vue/docs/components/badge/index.md +++ b/packages/devui-vue/docs/components/badge/index.md @@ -2,7 +2,7 @@ 图标右上角的圆形徽标数字。 -### 何时使用 +#### 何时使用 出现在图标右上角或列表项右方,通过不同的状态色加数字提示用户有消息需要处理时。 @@ -12,34 +12,51 @@ ```vue + + ``` ::: ### 点状徽章 -:::demo 点状徽章类型,当有包裹元素且 showDot 参数为 true 时为点状徽章,默认在右上角展示小点不显示数目。 +:::demo 点状徽章类型,当有包裹元素且 `show-dot` 参数为 true 时为点状徽章,默认在右上角展示小点不显示数目。 ```vue + + ``` ::: @@ -50,41 +67,57 @@ ```vue + + ``` ::: ### 状态徽章 -:::demo 当徽章独立使用、不包裹任何元素且 showDot 参数为 true 时为状态徽章,不同状态展示不同色点。 +:::demo 当徽章独立使用、不包裹任何元素且 `show-dot` 参数为 true 时为状态徽章,不同状态展示不同色点。 ```vue ``` @@ -93,20 +126,16 @@ ### 徽章位置 -:::demo 通过 badgePos 参数设置徽章位置。 +:::demo 通过 `position` 参数设置徽章位置。 ```vue @@ -116,64 +145,45 @@ ### 自定义 -:::demo 通过 bgColor 参数设置徽章展示状态色(此时 status 参数设置的徽章状态色失效),通过 offsetXY 参数可设置相对于 badgePos 的徽章偏移量。通过 textColor、bgColor 自定义文字、背景颜色。 +:::demo 通过 `bg-color` 参数设置徽章展示状态色(此时 `status` 参数设置的徽章状态色失效),通过 `offset` 参数可设置相对于 position 的徽章偏移量。通过 ` text-color``、bgColor ` 自定义文字、背景颜色。 ```vue ``` ::: -### API - -| 参数 | 类型 | 默认 | 说明 | -| :-------: | :-----------------: | :---------: | :--------------------------------------------------------------------------------------------------------------------------- | -| count | `Number` | -- | 可选,设置基本徽章和计数徽章中显示的数目 | -| maxCount | `Number` | 99 | 可选,设置基本徽章和计数徽章最大可显示数目,当 count > maxCount 时显示 maxCount+ | -| showDot | `Boolean` | false | 可选,true 时为点状徽章(有包裹)或状态徽章(无包裹),false 时为基本徽章(有包裹)或计数徽章(无包裹) | -| status | `BadgeStatusType` | -- | 可选,状态色 danger\| warning \| waiting \| success \| info | -| badgePos | `BadgePositionType` | 'top-right' | 可选,徽标位置 top-left\| top-right \| bottom-left \| bottom-right | -| bgColor | `String` | -- | 可选,自定义徽标色,此时 status 参数设置的徽章状态色失效 | -| textColor | `String` | -- | 可选, 可自定义徽标文字颜色 | -| offsetXY | `[number, number] ` | -- | 可选,可选,有包裹时徽标位置偏移量,格式为[x,y],单位为 px。x 为相对 right 或 left 的偏移量,y 为相对 top 或 bottom 的偏移量 | - - +### d-badge 参数 + +| 参数 | 类型 | 默认 | 说明 | +| ---------- | ------------------- | ----------- | :--------------------------------------------------------------------------------------------------------------------- | +| count | `Number` | -- | 可选,设置基本徽章和计数徽章中显示的数目 | +| max-count | `Number` | 99 | 可选,设置基本徽章和计数徽章最大可显示数目,当 count > `max-count` 时显示 `max-count+` | +| show-dot | `Boolean` | false | 可选,true 时为点状徽章(有包裹)或状态徽章(无包裹),false 时为基本徽章(有包裹)或计数徽章(无包裹) | +| status | `BadgeStatusType` | -- | 可选,状态色 danger\| warning \| waiting \| success \| info | +| position | `BadgePositionType` | 'top-right' | 可选,徽标位置 top-left\| top-right \| bottom-left \| bottom-right | +| bg-color | `String` | -- | 可选,自定义徽标色,此时 status 参数设置的徽章状态色失效 | +| text-color | `String` | -- | 可选, 可自定义徽标文字颜色 | +| offset | `[number, number]` | -- | 可选,有包裹时徽标位置偏移量,格式为[x,y],单位为 px。x 为相对 right 或 left 的偏移量,y 为相对 top 或 bottom 的偏移量 | + +### BadgeStatusType 类型 + +```typescript +type BadgeStatusType = 'danger' | 'warning' | 'waiting' | 'success' | 'info'; +``` + +### BadgePositionType 类型 + +```typescript +type BadgePositionType = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +``` diff --git a/packages/devui-vue/docs/components/button/index.md b/packages/devui-vue/docs/components/button/index.md index 23204dabbe..3023a010eb 100644 --- a/packages/devui-vue/docs/components/button/index.md +++ b/packages/devui-vue/docs/components/button/index.md @@ -6,200 +6,204 @@ 标记了一个(或封装一组)操作命令,响应用户点击行为,触发相应的业务逻辑。 -### 主要按钮 +### 按钮类型 -:::demo +:::demo 通过`variant`设置按钮类型,目前支持`solid`、`outline`、`text`三种类型,默认为`outline`类型。 ```vue + + ``` + ::: -### 次要按钮 -:::demo +### 主题色 + +:::demo 通过`color`设置按钮主题,目前支持`secondary`、`primary`、`danger`三种类型,默认为`secondary`类型。 ```vue ``` + ::: -### 左按钮与右按钮 +### 按钮大小 + +:::demo 通过`size`设置按钮大小,支持`xs`、`sm`、`md`、`lg`四种类型,默认为`md`。 -:::demo ```vue ``` + ::: +### 禁用按钮 -### 警示按钮 -用于标识系统中的关键操作,例如购买场景。 -:::demo -```vue - -``` -::: +:::demo 通过`disabled`参数设置按钮禁用状态。 -### 文字按钮 -用于标识系统中的关键操作,例如购买场景。 -:::demo ```vue ``` + ::: ### 加载中状态 -:::demo + +:::demo 通过`loading`参数设置按钮加载中状态。 + ```vue ``` -::: -### 自动获得焦点 -通过autofocus设置按钮自动获得焦点。 -:::demo -```vue - -``` ::: ### 图标 :::demo + ```vue ``` + ::: -### API -d-button 参数 -| 参数 | 类型 | 默认 | 说明 | -| :-------: | :-------------------------------: | :-------: | :------------------------------- | -| type | `'button' \| 'submit' \| 'reset'` | 'button' | 可选,按钮类型 | -| btnStyle | `IButtonStyle` | 'primary' | 可选,按钮风格 | -| position | `IButtonPosition` | 'default' | 可选,按钮位置 | -| size | `IButtonSize` | 'md' | 可选,按钮大小 | -| bordered | `boolean` | false | 可选,是否有边框 | -| icon | `string` | -- | 可选,点击背景触发的事件 | -| width | `string` | -- | 可选,弹出框宽度(e.g '300px') | -| disabled | `boolean` | false | 可选,是否禁用button | -| autofocus | `boolean` | false | 可选,按钮加载时是否自动获得焦点 | - -d-button 事件 -| 参数 | 类型 | 默认 | 说明 | -| :-----: | :---------------------------: | :---: | :------------- | -| onClick | `(event: MouseEvent) => void` | -- | 可选,点击事件 | - - -IButtonStyle +### d-button 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | +| -------- | ---------------- | ----------- | --------------------- | ------------------------- | +| variant | `IButtonVariant` | 'outline' | 可选,按钮的形态 | [按钮类型](#按钮类型) | +| color | `IButtonColor` | 'secondary' | 可选,按钮主题 | [主题色](#主题色) | +| size | `IButtonSize` | 'md' | 可选,按钮大小 | [按钮大小](#按钮大小) | +| icon | `string` | -- | 可选,自定义按钮图标 | [图标](#图标) | +| disabled | `boolean` | false | 可选,是否禁用 button | [禁用按钮](#禁用按钮) | +| loading | `boolean` | false | 可选,设置加载中状态 | [加载中状态](#加载中状态) | + +### IButtonVariant 类型 + ```typescript -type IButtonStyle = 'common' | 'primary' | 'text' | 'text-dark' | 'danger' | 'success' | 'warning'; +type IButtonVariant = 'solid' | 'outline' | 'text'; ``` -IButtonSize +### IButtonSize 类型 + ```typescript type IButtonSize = 'lg' | 'md' | 'sm' | 'xs'; ``` -IButtonPosition +### IButtonColor 类型 + ```typescript -type IButtonPosition = 'left' | 'right' | 'default'; +type IButtonColor = 'secondary' | 'primary' | 'danger'; ``` diff --git a/packages/devui-vue/docs/components/card/index.md b/packages/devui-vue/docs/components/card/index.md index c2cf535934..f304159ed2 100644 --- a/packages/devui-vue/docs/components/card/index.md +++ b/packages/devui-vue/docs/components/card/index.md @@ -39,7 +39,7 @@ + ### 参数 | 参数 | 类型 | 默认 | 说明 | 跳转 Demo | diff --git a/packages/devui-vue/docs/components/carousel/index.md b/packages/devui-vue/docs/components/carousel/index.md index c1f6450788..370a009c8d 100644 --- a/packages/devui-vue/docs/components/carousel/index.md +++ b/packages/devui-vue/docs/components/carousel/index.md @@ -117,9 +117,9 @@ export default defineComponent({ {{ item }} +``` + +::: + +### 颜色模式 + +设置 mode 展示响应颜色模式 +:::demo + +```vue + + + +``` + +::: + +### 历史颜色 + +自定义是否展示历史颜色 默认情况下为true +:::demo + +```vue + + + +``` + +::: + +### 基础面板自定义 +设置可自定义配置的基础面板颜色样本 +:::demo + +```vue + + + +``` + +::: + +### d-color-picker + +d-color-picker 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | +| :---: | :---: | :---: | :---: | :---: | +| mode | `String` | `rgb` | 切换颜色模式 | [颜色模式](#颜色模式) | | +| dotSize | `Number` | `15` | 调色板圆点大小 | | | +| swatches | `Array` | | 预定义样本面板 | [色块样本](#基础面板自定义) | | +| show-alpha | `Boolean` | `true` | 是否展示透明度进度条 | [透明度展示](#颜色透明度) | | +| show-history | `Boolean` | `true` | 是否展示历史颜色 | [历史颜色展示](#历史颜色) | | +| v-model | `String` | | 绑定颜色Value支持(hex , rgb , hsl , hsv ) | | | diff --git a/packages/devui-vue/docs/components/dragdrop/index.md b/packages/devui-vue/docs/components/dragdrop/index.md index f37a25bf6a..35f67ed833 100644 --- a/packages/devui-vue/docs/components/dragdrop/index.md +++ b/packages/devui-vue/docs/components/dragdrop/index.md @@ -15,19 +15,27 @@
Draggable Item
-
+
VSCode
+
Sublime
Drop Area
-
+
+
+
Drop Area With Sortable
+
+
diff --git a/packages/devui-vue/docs/components/drawer/index.md b/packages/devui-vue/docs/components/drawer/index.md index 7e838ddabc..c6a9fab12e 100644 --- a/packages/devui-vue/docs/components/drawer/index.md +++ b/packages/devui-vue/docs/components/drawer/index.md @@ -2,7 +2,7 @@ 屏幕边缘滑出的浮层面板组件。 -### 何时使用 +#### 何时使用 1. 抽屉从父窗体边缘滑入,覆盖住部分父窗体内容。用户在抽屉内操作时不必离开当前任务,操作完成后,可以平滑地回到到原任务。 2. 当需要一个附加的面板来控制父窗体内容,这个面板在需要时呼出。比如,控制界面展示样式,往界面中添加内容。 @@ -10,141 +10,161 @@ ### 基本用法 -

基本用法,可以控制全屏、关闭和设置宽度。

- -:::demo +:::demo 默认从右侧滑出,宽度为`300px`。 ```vue +``` - const drawerAfterOpened = () => { - console.log('open') - } +::: - const beforeHidden = () => { - return new Promise((resolve) => { - resolve(false); - }); - } +### 左侧弹出 - return { - isDrawerShow, - btnName, - drawerWidth, - drawerShow, - drawerClose, - drawerAfterOpened, - isCover, - backdropCloseable, - beforeHidden, - } - } -}) +:::demo 通过`position`设置左侧滑出。 + +```vue + + ``` ::: -### 自定义模板 +### 背景滚动 + +:::demo drawer 滑出之后,默认背景滚动会被锁定,可通过`lock-scroll`设置为`false`来解锁。 + +```vue + + + +``` + +::: -:::demo +### 关闭前回调 + +:::demo `before-close`在用户关闭 drawer 时会被调用,可在完成某些异步操作后,通过执行`done`函数关闭。 ```vue + +``` - const drawerShow = () => { - isDrawerShow.value = !isDrawerShow.value - } +::: - const drawerClose = () => { - isDrawerShow.value = false; - } +### 服务方式 + +:::demo 组件在全局注册了`$drawerService`,可通过服务的方式使用,drawer 的内容通过`content`参数传入。服务返回了用于关闭 drawer 的`close`方法。 + +```vue + - return { - isDrawerShow, - drawerShow, - drawerClose, + ``` ::: -### 参数及API - -| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | -| :---------: | :------: | :-------: | :----------------------- | --------------------------------- | -| v-model:visible | `Boolean` | `false` | 必选,设置抽屉板是否可见 | [基本用法](#基本用法) | -| width | `String` | `300px` | 可选,设置抽屉板宽度 | [基本用法](#基本用法) | -| zIndex | `Number` | `1000` | 可选,设置 drawer 的 z-index 值 | [基本用法](#基本用法) | -| isCover | `Boolean` | `true` | 可选,是否有遮罩层 | [基本用法](#基本用法) | -| escKeyCloseable | `Boolean` | `true` | 可选,设置可否通过 esc 按键来关闭 drawer 层 | [基本用法](#基本用法) | -| position | `String` | 'right' | 可选,抽屉板出现的位置,'left'或者'right' | [基本用法](#基本用法) | -| backdropCloseable | `Boolean` | true | 可选,设置可否通过点击背景来关闭 drawer 层 | [基本用法](#基本用法) | -| beforeHidden | `Function \| Promise` | -- | 可选,关闭窗口之前的回调 | [基本用法](#基本用法) | -| onClose | `Function` | -- | 可选,关闭 drawer 时候调用 | [基本用法](#基本用法) | -| onAfterOpened | `Function` | -- | 可选,打开 drawer 后时候调用 | [基本用法](#基本用法) | - -### 插槽 - -| 名称 | 类型 | 说明 | 跳转 Demo | -| :--: | :---------: | :------: | :-------: | -| default | 默认 | 抽屉板内容 | [自定义模板](#自定义模板) | -| header | 头部 | 抽屉板头部 | [自定义模板](#自定义模板) | +### d-drawer 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | +| ---------------------- | ---------------- | ------- | ------------------------------------------------- | ------------------------- | +| v-model | `Boolean` | `false` | 可选,设置抽屉板是否可见 | [基本用法](#基本用法) | +| position | `String` | `right` | 可选,抽屉板出现的位置,'left'或者'right' | [左侧弹出](#左侧弹出) | +| show-overlay | `Boolean` | `true` | 可选,是否有遮罩层 | [基本用法](#基本用法) | +| lock-scroll | `Boolean` | `true` | 可选,是否锁定滚动 | [背景滚动](#背景滚动) | +| z-index | `Number` | `1000` | 可选,设置 drawer 的 z-index 值 | [基本用法](#基本用法) | +| esc-key-closeable | `Boolean` | `true` | 可选,设置可否通过 esc 按键来关闭 drawer 层 | [基本用法](#基本用法) | +| close-on-click-overlay | `Boolean` | `true` | 可选,设置可否通过点击背景来关闭 drawer 层 | [基本用法](#基本用法) | +| before-close | `(done) => void` | `-` | 可选,关闭窗口前的回调,调用 `done` 可关闭 drawer | [关闭前回调](#关闭前回调) | + +### d-drawer 事件 + +| 事件名 | 类型 | 说明 | +| ------ | ---- | ----------------- | +| open | `-` | drawer 打开时触发 | +| close | `-` | drawer 关闭时触发 | + +### d-drawer 插槽 + +| 名称 | 类型 | 说明 | 跳转 Demo | +| ------- | ---- | ---------- | --------------------- | +| default | 默认 | 抽屉板内容 | [基本用法](#基本用法) | diff --git a/packages/devui-vue/docs/components/dropdown/index.md b/packages/devui-vue/docs/components/dropdown/index.md index 061c602400..19820ff951 100644 --- a/packages/devui-vue/docs/components/dropdown/index.md +++ b/packages/devui-vue/docs/components/dropdown/index.md @@ -1,134 +1,327 @@ # Dropdown 下拉菜单 -按下弹出列表组件。 -### 何时使用 -当页面上的操作命令过多时,用此组件可以收纳操作元素。点击或移入触点,会出现一个下拉菜单。可在列表中进行选择,并执行相应的命令。 +按下弹出列表组件。 +#### 何时使用 +当页面上的操作命令过多时,用此组件可以收纳操作元素。点击或移入触点,会出现一个下拉菜单。可在列表中进行选择,并执行相应的命令。 ### 基本用法 -:::demo +:::demo 组件默认插槽中定义触发元素,`menu`插槽中定义菜单。默认通过点击触发元素展开菜单。 ```vue -
- 仅当鼠标从菜单移除时才关闭: - -
+ + + +``` + +::: + +### 触发方式 + +:::demo 组件默认通过`click`方式展开;`hover`方式为鼠标移上触发元素展开菜单;`manually`方式为手动控制,通过设置`visible`来控制组件是否展开。 -
- 动画开关: - +```vue + + +``` + +::: + +### 可关闭区域 + +:::demo 通过`close-scope`参数设置点击关闭区域,默认值为`all`表示点击菜单内外都关闭,`blank`点击非菜单空白才关闭,`none`菜单内外均不关闭仅下拉按键可以关闭。 + +```vue + - closeScope: ref('blank'), + +``` - +:::demo + +```vue + + + +``` + +::: + +### 单独使用 DropdownMenu + +:::demo + +```vue + + + ``` + ::: -### d-dropdown +### d-dropdown 参数 + +| 参数 | 类型 | 默认 | 说明 | +| ------------------------- | ------------------------- | ------------ | --------------------------------------------------------------------------------------------- | +| visible | `boolean` | `false` | 可选,可以显示指定 dropdown 是否打开 | +| trigger | `TriggerType` | `click` | 可选,dropdown 触发方式, click 为点、hover 为悬停、manually 为完全手动控制 | +| close-scope | `CloseScopeArea` | `all` | 可选,点击关闭区域,blank 点击非菜单空白关闭, all 点击菜单内外关闭,none 仅触发元素关闭 | +| position | `Placement[]` | `['bottom']` | 可选,展开位置,若位置包含`start`或`end`,需通过`align`参数设置对齐方式 | +| align | `start \| end \| null` | `null` | 可选,对齐方式,默认居中对齐。若指定`start`对齐,当`start`位置放不下时,会自动调整为`end`对齐 | +| offset | `number \| OffsetOptions` | `4` | 可选,指定与触发元素的间距 | +| close-on-mouse-leave-menu | `boolean` | `false` | 可选,是否进入菜单后离开菜单的时候关闭菜单 | + +### d-dropdown 事件 + +| 事件名 | 说明 | 参数 | +| ------ | ------------------------------------------------------------- | ----------------------- | +| toggle | 组件收起和展开的布尔值,true 表示将要展开,false 表示将要关闭 | `EventEmitter` | -d-dropdown 参数 +### d-dropdown 插槽 -| 参数 | 类型 | 默认 | 说明 | -| --------------------- | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------- | -| origin | `Element \| ComponentPublicInstance` | 无 | 必选,必须指定 dropdown 的关联元素 | -| isOpen | `boolean` | `false` | 可选,可以显示指定 dropdown 是否打开 | -| disabled | `boolean` | `false` | 可选,设置为 true 禁用 dropdown | -| trigger | `TriggerType` | `click` | 可选,dropdown 触发方式, click 为点击,hover 为悬停(也包含点击)、manually 为完全手动控制 | -| closeScope | `CloseScopeArea` | `all` | 可选,点击关闭区域,blank 点击非菜单空白才关闭, all 点击菜单内外都关闭,none 菜单内外均不关闭仅下拉按键可以关闭 | -| closeOnMouseLeaveMenu | `boolean` | `false` | 可选,是否进入菜单后离开菜单的时候关闭菜单 | -| showAnimation | `boolean` | `true` | 可选,是否开启动画 | +| 名称 | 说明 | +| ------- | -------------------- | +| default | 菜单打开时的触发元素 | +| menu | 下拉菜单的内容 | + +### d-dropdown-menu 参数 + +| 参数 | 类型 | 默认 | 说明 | +| ------------- | ------------------------- | ------------ | --------------------------------------------------------------------------------------------- | +| origin | `HTMLElement` | `-` | 必选,必须指定 DropdownMenu 的关联元素 | +| v-model | `boolean` | `false` | 必选,指定 DropdownMenu 是否打开 | +| position | `Placement[]` | `['bottom']` | 可选,展开位置,若位置包含`start`或`end`,需通过`align`参数设置对齐方式 | +| align | `start \| end \| null` | `null` | 可选,对齐方式,默认居中对齐。若指定`start`对齐,当`start`位置放不下时,会自动调整为`end`对齐 | +| offset | `number \| OffsetOptions` | `4` | 可选,指定与触发元素的间距 | +| close-outside | `() => boolean` | `() => true` | 可选,点击外部区域的回调函数,默认返回 true,点击外部区域会关闭 DropdownMenu | + +### TriggerType 类型 -TriggerType 类型 ```typescript type TriggerType = 'click' | 'hover' | 'manually'; ``` -CloseScopeArea 类型 +### CloseScopeArea 类型 + ```typescript type CloseScopeArea = 'all' | 'blank' | 'none'; -``` \ No newline at end of file +``` + +### Placement 类型 + +```typescript +type Placement = + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'top-start' + | 'top-end' + | 'right-start' + | 'right-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end'; +``` + +### OffsetOptions 类型 + +```typescript +type OffsetOptions = { mainAxis?: number; crossAxis?: number }; +``` diff --git a/packages/devui-vue/docs/components/editable-select/index.md b/packages/devui-vue/docs/components/editable-select/index.md index f0a769a4ce..9beec0cd55 100644 --- a/packages/devui-vue/docs/components/editable-select/index.md +++ b/packages/devui-vue/docs/components/editable-select/index.md @@ -1,258 +1,321 @@ -# EditableSelect 可输入下拉选择框 - -同时支持输入和下拉选择的输入框。 - -### 何时使用 - -当需要同时支持用户输入数据和选择已有数据的时候使用,加入输入联想功能,方便用户搜索已有数据。 - -### 基本用法 - -通过 options 设置数据源。 - -:::demo - -```vue - - - -``` - -::: - -### 设置禁用选项 - -支持禁用指定数据。 -:::demo - -```vue - - -``` - -::: - -### 异步获取数据源并设置匹配方法 - -支持异步设置数据源并设置匹配方法。 -:::demo - -```vue - - -``` - -::: - -### 懒加载 - -:::demo - -```vue - - -``` - -::: - -### d-editable-select - -d-editable-select 参数 - -| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | 全局配置 | -| -------------- | ------------- | ----- | -------------------------------------------------- | ----------------------------- | -------- | -| appendToBody | boolean | false | 可选,下拉是否 appendToBody | [基本用法](#基本用法) | | -| width | number | -- | 可选,控制下拉框宽度,搭配 appendToBody 使用(px) | [基本用法](#基本用法) | | -| v-model | string/number | -- | 绑定值 | [基本用法](#基本用法) | | -| options | Array | -- | 必选,数据列表 | [基本用法](#基本用法) | | -| disabled | boolean | false | 可选,值为 true 禁用下拉框 | [设置禁用选项](#设置禁用选项) | | -| disabledKey | string | -- | 可选,设置禁用选项的 Key 值 | [设置禁用选项](#设置禁用选项) | | -| maxHeight | number | -- | 可选,下拉菜单的最大高度(px) | [基本用法](#基本用法) | | -| remote | boolean | false | 可选,远程搜索 | | | -| enableLazyLoad | boolean | false | 可选,是否允许懒加载 | [懒加载](#懒加载) | | - -d-editable-select 事件 - -| 事件 | 类型 | 说明 | 跳转 Demo | -| ------------ | ---- | ------------------ | -------------------------------------------------------- | -| filterMethod | | 自定义筛选函数 | | -| remoteMethod | | 远程搜索对应的函数 | [异步获取数据源并设置匹配方法](#异步获取数据源并设置匹配方法) | -| loadMore | | 懒加载 | [懒加载](#懒加载) | - -d-editable-select 插槽 - -| name | 说明 | -| ------- | ------------------ | -| default | Option 模板 | -| empty | 无 Option 时的列表 | +# EditableSelect 可输入下拉选择框 + +同时支持输入和下拉选择的输入框。 + +### 何时使用 + +当需要同时支持用户输入数据和选择已有数据的时候使用,加入输入联想功能,方便用户搜索已有数据。 + +### 基本用法 + +通过 source 设置数据源。 +:::demo // todo 展开代码的内部描述 + +```vue + + + +``` + +::: + +### 设置禁用 + +:::demo + +```vue + + +``` + +::: + +### 自定义数据匹配方法 + +:::demo + +```vue + + +``` + +::: + +### 自定义模板展示 + +:::demo + +```vue + + +``` + +::: + +### 懒加载 + +:::demo + +```vue + + + +``` + +::: + +### d-editable-select + +d-editable-select 参数 + +| 参数 | 类型 | 默认 | 说明 | 跳转 Demo | +| ------------ | ---------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | --------------------- | +| options | `Array` | [] | 可选数据列表 | [基本用法](#基本用法) | +| appendToBody | `boolean` | false | 可选,下拉是否 appendToBody | [基本用法](#基本用法) | +| placeholder | `string` | `Search` | 下拉框的默认提示文字 | [基本用法](#基本用法) | +| maxHeight | number | | | +| disabled | `boolean` | false | 可选,值为 true 禁用下拉框 | [设置禁用](#设置禁用) | +| disabledKey | `string` | -- | 可选,设置禁用选项的 Key 值 | [设置禁用](#设置禁用) | +| filterOption | `boolean\|(inputValue,options)=>boolean` | true | 当其为一个函数时,会接收 inputValue option 两个参数,当 option 符合筛选条件时,应返回 true,反之则返回 false。 | + +d-editable-select 事件 + +| 事件 | 类型 | 说明 | 跳转 Demo | +| -------- | ------------------------------ | ---------------------------------------- | ----------------------------------------- | +| loadMore | `(inputvalue:string)=>void ` | 懒加载触发事件,配合 enableLazyLoad 使用 | [懒加载](#懒加载) | +| search | ` (inputvalue:string)=>bolean` | 文本框值变化时回调 | [自定义数据匹配方法](#自定义数据匹配方法) | + +d-editable-select 插槽 +| 插槽名|说明 |跳转 Demo| +| ---- | -- | ------- | +| itemTemplate | 可选,下拉菜单条目的模板 | [自定义模板展示](#自定义模板展示) | +| noResultItemTemplate | 可选,下拉菜单条目搜索后没有结果的模板 | [自定义模板展示](#自定义模板展示) | diff --git a/packages/devui-vue/docs/components/form/index.md b/packages/devui-vue/docs/components/form/index.md index 13591a26d1..8978ba33cd 100644 --- a/packages/devui-vue/docs/components/form/index.md +++ b/packages/devui-vue/docs/components/form/index.md @@ -1,6 +1,6 @@ # Form 表单 -表单用于收集数据 +具有数据收集、校验和提交功能的表单,包含复选框、单选框、输入框、下拉选择框等元素。 ### 何时使用 @@ -10,8 +10,6 @@ ### 基础用法 -> done - 基本用法当中,Label是在数据框的上面。 @@ -21,25 +19,25 @@ @@ -1168,11 +1157,11 @@ export default defineComponent({ @@ -1183,8 +1172,6 @@ export default defineComponent({ ### 响应式表单验证 -> done - 在`d-form`标签中指定校验规则rules,同时在`d-form-item`中指定`prop`的值为校验字段名。 @@ -1194,13 +1181,13 @@ export default defineComponent({