├── .github ├── FUNDING.yml ├── pr-badge.yml ├── settings.yml └── workflows │ ├── main.yml │ └── push.yml ├── .gitignore ├── .gitpod.yml ├── .husky ├── pre-commit └── pre-push ├── .npmignore ├── .npmrc ├── .parcelrc ├── .vscode ├── extensions.json └── launch.json ├── License ├── ReadMe.md ├── eslint.config.ts ├── guide ├── Contributing.md ├── Migrating-zh.md ├── Migrating.md ├── ReadMe-zh.md ├── pack-docs.sh └── table.css ├── jest.config.ts ├── package.json ├── pnpm-lock.yaml ├── preview ├── .parcelrc ├── Async.tsx ├── Clock.tsx ├── Field.tsx ├── Home.tsx ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── utility.ts ├── source ├── Animation │ ├── index.tsx │ └── type.ts ├── Async.tsx ├── WebCell.tsx ├── WebField.ts ├── decorator.ts ├── index.ts ├── polyfill.ts └── utility.ts ├── test ├── Async.spec.tsx ├── MobX.spec.tsx ├── WebCell.spec.tsx ├── WebField.spec.tsx └── tsconfig.json └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: 2 | - https://paypal.me/TechQuery 3 | - https://tech-query.me/image/TechQuery-Alipay.jpg 4 | -------------------------------------------------------------------------------- /.github/pr-badge.yml: -------------------------------------------------------------------------------- 1 | - icon: visualstudio 2 | label: 'GitHub.dev' 3 | message: 'PR-$prNumber' 4 | color: 'blue' 5 | url: 'https://github.dev/$owner/$repo/pull/$prNumber' 6 | 7 | - icon: github 8 | label: 'GitHub codespaces' 9 | message: 'PR-$prNumber' 10 | color: 'black' 11 | url: 'https://codespaces.new/$owner/$repo/pull/$prNumber' 12 | 13 | - icon: git 14 | label: 'GitPod.io' 15 | message: 'PR-$prNumber' 16 | color: 'orange' 17 | url: 'https://gitpod.io/?autostart=true#https://github.com/$owner/$repo/pull/$prNumber' 18 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | allow_merge_commit: false 5 | 6 | delete_branch_on_merge: true 7 | 8 | enable_vulnerability_alerts: true 9 | 10 | labels: 11 | - name: bug 12 | color: '#d73a4a' 13 | description: Something isn't working 14 | 15 | - name: documentation 16 | color: '#0075ca' 17 | description: Improvements or additions to documentation 18 | 19 | - name: duplicate 20 | color: '#cfd3d7' 21 | description: This issue or pull request already exists 22 | 23 | - name: enhancement 24 | color: '#a2eeef' 25 | description: Some improvements 26 | 27 | - name: feature 28 | color: '#16b33f' 29 | description: New feature or request 30 | 31 | - name: good first issue 32 | color: '#7057ff' 33 | description: Good for newcomers 34 | 35 | - name: help wanted 36 | color: '#008672' 37 | description: Extra attention is needed 38 | 39 | - name: invalid 40 | color: '#e4e669' 41 | description: This doesn't seem right 42 | 43 | - name: question 44 | color: '#d876e3' 45 | description: Further information is requested 46 | 47 | - name: wontfix 48 | color: '#ffffff' 49 | description: This will not be worked on 50 | 51 | branches: 52 | - name: main 53 | # https://docs.github.com/en/rest/reference/repos#update-branch-protection 54 | protection: 55 | # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. 56 | required_pull_request_reviews: 57 | # The number of approvals required. (1-6) 58 | required_approving_review_count: 1 59 | # Dismiss approved reviews automatically when a new commit is pushed. 60 | dismiss_stale_reviews: true 61 | # Blocks merge until code owners have reviewed. 62 | require_code_owner_reviews: true 63 | # Specify which users and teams can dismiss pull request reviews. 64 | # Pass an empty dismissal_restrictions object to disable. 65 | # User and team dismissal_restrictions are only available for organization-owned repositories. 66 | # Omit this parameter for personal repositories. 67 | dismissal_restrictions: 68 | # users: [] 69 | # teams: [] 70 | # Required. Require status checks to pass before merging. Set to null to disable 71 | required_status_checks: 72 | # Required. Require branches to be up to date before merging. 73 | strict: true 74 | # Required. The list of status checks to require in order to merge into this branch 75 | contexts: [] 76 | # Required. Enforce all configured restrictions for administrators. 77 | # Set to true to enforce required status checks for repository administrators. 78 | # Set to null to disable. 79 | enforce_admins: true 80 | # Prevent merge commits from being pushed to matching branches 81 | required_linear_history: true 82 | # Required. Restrict who can push to this branch. 83 | # Team and user restrictions are only available for organization-owned repositories. 84 | # Set to null to disable. 85 | restrictions: null 86 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI & CD 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | Build-and-Publish: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | with: 17 | version: 9 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | registry-url: https://registry.npmjs.org 22 | cache: pnpm 23 | - name: Install Dependencies 24 | run: pnpm i --frozen-lockfile 25 | 26 | - name: Build & Publish 27 | run: npm publish --access public --provenance 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - name: Update document 32 | uses: peaceiris/actions-gh-pages@v4 33 | with: 34 | publish_dir: ./docs 35 | personal_token: ${{ secrets.GITHUB_TOKEN }} 36 | force_orphan: true 37 | 38 | - uses: amondnet/vercel-action@v25 39 | with: 40 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 43 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} 44 | working-directory: ./docs 45 | vercel-args: --prod 46 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Commit preview 2 | on: 3 | push: 4 | branches-ignore: 5 | - main 6 | jobs: 7 | Build-and-Deploy: 8 | env: 9 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 10 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 11 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: pnpm/action-setup@v4 17 | with: 18 | version: 9 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 22 22 | cache: pnpm 23 | - name: Install & Build 24 | run: | 25 | pnpm i --frozen-lockfile 26 | pnpm build 27 | 28 | - uses: amondnet/vercel-action@v25 29 | if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }} 30 | with: 31 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 34 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} 35 | working-directory: ./docs 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | yarn.lock 4 | dist/ 5 | .parcel-cache/ 6 | docs/ 7 | .vscode/settings.json 8 | .vercel 9 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | vscode: 8 | extensions: 9 | - yzhang.markdown-all-in-one 10 | - redhat.vscode-yaml 11 | - akamud.vscode-caniuse 12 | - visualstudioexptteam.intellicode-api-usage-examples 13 | - pflannery.vscode-versionlens 14 | - christian-kohler.npm-intellisense 15 | - esbenp.prettier-vscode 16 | - eamodio.gitlens 17 | - github.vscode-pull-request-github 18 | - github.vscode-github-actions 19 | tasks: 20 | - init: pnpm i 21 | command: npm test 22 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run build 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.sh 2 | .* 3 | eslint.config.mjs 4 | jest.config.ts 5 | test/ 6 | preview/ 7 | Contributing.md 8 | docs/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = false 2 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{ts,tsx}": [ 5 | "@parcel/transformer-typescript-tsc" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "yzhang.markdown-all-in-one", 4 | "redhat.vscode-yaml", 5 | "akamud.vscode-caniuse", 6 | "visualstudioexptteam.intellicode-api-usage-examples", 7 | "pflannery.vscode-versionlens", 8 | "christian-kohler.npm-intellisense", 9 | "esbenp.prettier-vscode", 10 | "eamodio.gitlens", 11 | "github.vscode-pull-request-github", 12 | "github.vscode-github-actions" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest", 6 | "type": "node", 7 | "request": "launch", 8 | "port": 9229, 9 | "runtimeArgs": [ 10 | "--inspect-brk", 11 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 12 | "--runInBand" 13 | ], 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /License: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | 167 | --- 168 | 169 | 版权所有(c)2019 - 2024 TechQuery 170 | 171 | 反 996 许可证版本 1.0(草案) 172 | 173 | 在符合下列条件的情况下, 174 | 特此免费向任何得到本授权作品的副本(包括源代码、文件和/或相关内容,以下统称为“授权作品” 175 | )的个人和法人实体授权:被授权个人或法人实体有权以任何目的处置授权作品,包括但不限于使 176 | 用、复制,修改,衍生利用、散布,发布和再许可: 177 | 178 | 179 | 1. 个人或法人实体必须在许可作品的每个再散布或衍生副本上包含以上版权声明和本许可证,不 180 | 得自行修改。 181 | 2. 个人或法人实体必须严格遵守与个人实际所在地或个人出生地或归化地、或法人实体注册地或 182 | 经营地(以较严格者为准)的司法管辖区所有适用的与劳动和就业相关法律、法规、规则和 183 | 标准。如果该司法管辖区没有此类法律、法规、规章和标准或其法律、法规、规章和标准不可 184 | 执行,则个人或法人实体必须遵守国际劳工标准的核心公约。 185 | 3. 个人或法人不得以任何方式诱导或强迫其全职或兼职员工或其独立承包人以口头或书面形式同 186 | 意直接或间接限制、削弱或放弃其所拥有的,受相关与劳动和就业有关的法律、法规、规则和 187 | 标准保护的权利或补救措施,无论该等书面或口头协议是否被该司法管辖区的法律所承认,该 188 | 等个人或法人实体也不得以任何方法限制其雇员或独立承包人向版权持有人或监督许可证合规 189 | 情况的有关当局报告或投诉上述违反许可证的行为的权利。 190 | 191 | 该授权作品是"按原样"提供,不做任何明示或暗示的保证,包括但不限于对适销性、特定用途适用 192 | 性和非侵权性的保证。在任何情况下,无论是在合同诉讼、侵权诉讼或其他诉讼中,版权持有人均 193 | 不承担因本软件或本软件的使用或其他交易而产生、引起或与之相关的任何索赔、损害或其他责任。 194 | 195 | 196 | ------------------------- ENGLISH ------------------------------ 197 | 198 | 199 | Copyright (c) 2019 - 2024 TechQuery 200 | 201 | Anti 996 License Version 1.0 (Draft) 202 | 203 | Permission is hereby granted to any individual or legal entity obtaining a copy 204 | of this licensed work (including the source code, documentation and/or related 205 | items, hereinafter collectively referred to as the "licensed work"), free of 206 | charge, to deal with the licensed work for any purpose, including without 207 | limitation, the rights to use, reproduce, modify, prepare derivative works of, 208 | publish, distribute and sublicense the licensed work, subject to the following 209 | conditions: 210 | 211 | 1. The individual or the legal entity must conspicuously display, without 212 | modification, this License on each redistributed or derivative copy of the 213 | Licensed Work. 214 | 215 | 2. The individual or the legal entity must strictly comply with all applicable 216 | laws, regulations, rules and standards of the jurisdiction relating to 217 | labor and employment where the individual is physically located or where 218 | the individual was born or naturalized; or where the legal entity is 219 | registered or is operating (whichever is stricter). In case that the 220 | jurisdiction has no such laws, regulations, rules and standards or its 221 | laws, regulations, rules and standards are unenforceable, the individual 222 | or the legal entity are required to comply with Core International Labor 223 | Standards. 224 | 225 | 3. The individual or the legal entity shall not induce or force its 226 | employee(s), whether full-time or part-time, or its independent 227 | contractor(s), in any methods, to agree in oral or written form, 228 | to directly or indirectly restrict, weaken or relinquish his or 229 | her rights or remedies under such laws, regulations, rules and 230 | standards relating to labor and employment as mentioned above, 231 | no matter whether such written or oral agreement are enforceable 232 | under the laws of the said jurisdiction, nor shall such individual 233 | or the legal entity limit, in any methods, the rights of its employee(s) 234 | or independent contractor(s) from reporting or complaining to the copyright 235 | holder or relevant authorities monitoring the compliance of the license 236 | about its violation(s) of the said license. 237 | 238 | THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 239 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 240 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT 241 | HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 242 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN ANY WAY CONNECTION 243 | WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK. 244 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # WebCell 2 | 3 | ![WebCell logo](https://web-cell.dev/WebCell-0.f9823b00.png) 4 | 5 | [简体中文](./guide/ReadMe-zh.md) | English 6 | 7 | [Web Components][1] engine based on VDOM, [JSX][2], [MobX][3] & [TypeScript][4] 8 | 9 | [![NPM Dependency](https://img.shields.io/librariesio/github/EasyWebApp/WebCell.svg)][5] 10 | [![CI & CD](https://github.com/EasyWebApp/WebCell/actions/workflows/main.yml/badge.svg)][6] 11 | 12 | [![Anti 996 license](https://img.shields.io/badge/license-Anti%20996-blue.svg)][7] 13 | [![UI library recommendation list](https://jaywcjlove.github.io/sb/ico/awesome.svg)][8] 14 | 15 | [![Slideshow](https://img.shields.io/badge/learn-Slideshow-blue)][9] 16 | [![Gitter](https://badges.gitter.im/EasyWebApp/community.svg)][10] 17 | 18 | [![Edit WebCell demo](https://codesandbox.io/static/img/play-codesandbox.svg)][11] 19 | 20 | [![NPM](https://nodei.co/npm/web-cell.png?downloads=true&downloadRank=true&stars=true)][12] 21 | 22 | ## Feature 23 | 24 | ### Engines comparison 25 | 26 | | feature | WebCell 3 | WebCell 2 | React | Vue | 27 | | :-----------: | :------------------------: | :------------------: | :---------------------------: | :---------------------------------: | 28 | | JS language | [TypeScript 5][13] | TypeScript 4 | ECMAScript or TypeScript | ECMAScript or TypeScript | 29 | | JS syntax | [ES decorator stage-3][14] | ES decorator stage-2 | | | 30 | | XML syntax | [JSX import][15] | JSX factory | JSX factory/import | HTML/Vue template or JSX (optional) | 31 | | DOM API | [Web components][16] | Web components | HTML 5+ | HTML 5+ | 32 | | view renderer | [DOM Renderer 2][17] | SnabbDOM | (built-in) | SnabbDOM (forked) | 33 | | state API | [MobX `@observable`][18] | `this.state` | `this.state` or `useState()` | `this.$data` or `ref()` | 34 | | props API | MobX `@observable` | `@watch` | `this.props` or `props => {}` | `this.$props` or `defineProps()` | 35 | | state manager | [MobX 6+][19] | MobX 4/5 | Redux | VueX | 36 | | page router | [JSX][20] tags | JSX tags + JSON data | JSX tags | JSON data | 37 | | asset bundler | [Parcel 2][21] | Parcel 1 | webpack | Vite | 38 | 39 | ## Installation 40 | 41 | ```shell 42 | npm install dom-renderer mobx web-cell 43 | ``` 44 | 45 | ## Web browser usage 46 | 47 | [Demo & **GitHub template**][22] 48 | 49 | ### Project bootstrap 50 | 51 | #### Tool chain 52 | 53 | ```shell 54 | npm install parcel @parcel/config-default @parcel/transformer-typescript-tsc -D 55 | ``` 56 | 57 | #### `package.json` 58 | 59 | ```json 60 | { 61 | "scripts": { 62 | "start": "parcel source/index.html --open", 63 | "build": "parcel build source/index.html --public-url ." 64 | } 65 | } 66 | ``` 67 | 68 | #### `tsconfig.json` 69 | 70 | ```json 71 | { 72 | "compilerOptions": { 73 | "target": "ES6", 74 | "module": "ES2020", 75 | "moduleResolution": "Node", 76 | "useDefineForClassFields": true, 77 | "jsx": "react-jsx", 78 | "jsxImportSource": "dom-renderer" 79 | } 80 | } 81 | ``` 82 | 83 | #### `.parcelrc` 84 | 85 | ```json 86 | { 87 | "extends": "@parcel/config-default", 88 | "transformers": { 89 | "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"] 90 | } 91 | } 92 | ``` 93 | 94 | #### `source/index.html` 95 | 96 | ```html 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | ``` 105 | 106 | ### Function component 107 | 108 | ```tsx 109 | import { DOMRenderer } from 'dom-renderer'; 110 | import { FC, PropsWithChildren } from 'web-cell'; 111 | 112 | const Hello: FC = ({ children = 'World' }) => ( 113 |

Hello, {children}!

114 | ); 115 | 116 | new DOMRenderer().render(WebCell); 117 | ``` 118 | 119 | ### Class component 120 | 121 | #### Children slot 122 | 123 | ```tsx 124 | import { DOMRenderer } from 'dom-renderer'; 125 | import { component } from 'web-cell'; 126 | 127 | @component({ 128 | tagName: 'hello-world', 129 | mode: 'open' 130 | }) 131 | class Hello extends HTMLElement { 132 | render() { 133 | return ( 134 |

135 | Hello, ! 136 |

137 | ); 138 | } 139 | } 140 | 141 | new DOMRenderer().render( 142 | <> 143 | WebCell 144 | {/* or */} 145 | WebCell 146 | 147 | ); 148 | ``` 149 | 150 | #### DOM Props 151 | 152 | ```tsx 153 | import { DOMRenderer } from 'dom-renderer'; 154 | import { observable } from 'mobx'; 155 | import { WebCell, component, attribute, observer } from 'web-cell'; 156 | 157 | interface HelloProps { 158 | name?: string; 159 | } 160 | 161 | interface Hello extends WebCell {} 162 | 163 | @component({ tagName: 'hello-world' }) 164 | @observer 165 | class Hello extends HTMLElement implements WebCell { 166 | @attribute 167 | @observable 168 | accessor name = ''; 169 | 170 | render() { 171 | return

Hello, {this.name}!

; 172 | } 173 | } 174 | 175 | new DOMRenderer().render(); 176 | 177 | // or for HTML tag props in TypeScript 178 | 179 | declare global { 180 | namespace JSX { 181 | interface IntrinsicElements { 182 | 'hello-world': HelloProps; 183 | } 184 | } 185 | } 186 | new DOMRenderer().render(); 187 | ``` 188 | 189 | ### Inner state 190 | 191 | #### Function component 192 | 193 | ```tsx 194 | import { DOMRenderer } from 'dom-renderer'; 195 | import { observable } from 'mobx'; 196 | import { FC, observer } from 'web-cell'; 197 | 198 | class CounterModel { 199 | @observable 200 | accessor times = 0; 201 | } 202 | 203 | const couterStore = new CounterModel(); 204 | 205 | const Counter: FC = observer(() => ( 206 | 209 | )); 210 | 211 | new DOMRenderer().render(); 212 | ``` 213 | 214 | #### Class component 215 | 216 | ```tsx 217 | import { DOMRenderer } from 'dom-renderer'; 218 | import { observable } from 'mobx'; 219 | import { component, observer } from 'web-cell'; 220 | 221 | @component({ tagName: 'my-counter' }) 222 | @observer 223 | class Counter extends HTMLElement { 224 | @observable 225 | accessor times = 0; 226 | 227 | handleClick = () => (this.times += 1); 228 | 229 | render() { 230 | return ; 231 | } 232 | } 233 | 234 | new DOMRenderer().render(); 235 | ``` 236 | 237 | ### CSS scope 238 | 239 | #### Inline style 240 | 241 | ```tsx 242 | import { component } from 'web-cell'; 243 | import { stringifyCSS } from 'web-utility'; 244 | 245 | @component({ 246 | tagName: 'my-button', 247 | mode: 'open' 248 | }) 249 | export class MyButton extends HTMLElement { 250 | style = stringifyCSS({ 251 | '.btn': { 252 | color: 'white', 253 | background: 'lightblue' 254 | } 255 | }); 256 | 257 | render() { 258 | return ( 259 | <> 260 | 261 | 262 | 263 | 264 | 265 | 266 | ); 267 | } 268 | } 269 | ``` 270 | 271 | #### Link stylesheet 272 | 273 | ```tsx 274 | import { component } from 'web-cell'; 275 | 276 | @component({ 277 | tagName: 'my-button', 278 | mode: 'open' 279 | }) 280 | export class MyButton extends HTMLElement { 281 | render() { 282 | return ( 283 | <> 284 | 288 | 289 | 290 | 291 | 292 | ); 293 | } 294 | } 295 | ``` 296 | 297 | #### CSS module 298 | 299 | ##### `scoped.css` 300 | 301 | ```css 302 | .btn { 303 | color: white; 304 | background: lightblue; 305 | } 306 | ``` 307 | 308 | ##### `MyButton.tsx` 309 | 310 | ```tsx 311 | import { WebCell, component } from 'web-cell'; 312 | 313 | import styles from './scoped.css' assert { type: 'css' }; 314 | 315 | interface MyButton extends WebCell {} 316 | 317 | @component({ 318 | tagName: 'my-button', 319 | mode: 'open' 320 | }) 321 | export class MyButton extends HTMLElement implements WebCell { 322 | connectedCallback() { 323 | this.root.adoptedStyleSheets = [styles]; 324 | } 325 | 326 | render() { 327 | return ( 328 | 329 | 330 | 331 | ); 332 | } 333 | } 334 | ``` 335 | 336 | ### Event delegation 337 | 338 | ```tsx 339 | import { component, on } from 'web-cell'; 340 | 341 | @component({ tagName: 'my-table' }) 342 | export class MyTable extends HTMLElement { 343 | @on('click', ':host td > button') 344 | handleEdit(event: MouseEvent, { dataset: { id } }: HTMLButtonElement) { 345 | console.log(`editing row: ${id}`); 346 | } 347 | 348 | render() { 349 | return ( 350 | 351 | 352 | 353 | 354 | 357 | 358 | 359 | 360 | 361 | 364 | 365 | 366 | 367 | 368 | 371 | 372 |
1A 355 | 356 |
2B 362 | 363 |
3C 369 | 370 |
373 | ); 374 | } 375 | } 376 | ``` 377 | 378 | ### MobX reaction 379 | 380 | ```tsx 381 | import { observable } from 'mobx'; 382 | import { component, observer, reaction } from 'web-cell'; 383 | 384 | @component({ tagName: 'my-counter' }) 385 | @observer 386 | export class Counter extends HTMLElement { 387 | @observable 388 | accessor times = 0; 389 | 390 | handleClick = () => (this.times += 1); 391 | 392 | @reaction(({ times }) => times) 393 | echoTimes(newValue: number, oldValue: number) { 394 | console.log(`newValue: ${newValue}, oldValue: ${oldValue}`); 395 | } 396 | 397 | render() { 398 | return ; 399 | } 400 | } 401 | ``` 402 | 403 | ### Form association 404 | 405 | ```tsx 406 | import { DOMRenderer } from 'dom-renderer'; 407 | import { WebField, component, formField, observer } from 'web-cell'; 408 | 409 | interface MyField extends WebField {} 410 | 411 | @component({ 412 | tagName: 'my-field', 413 | mode: 'open' 414 | }) 415 | @formField 416 | @observer 417 | class MyField extends HTMLElement implements WebField { 418 | render() { 419 | const { name } = this; 420 | 421 | return ( 422 | 425 | (this.value = value) 426 | } 427 | /> 428 | ); 429 | } 430 | } 431 | 432 | new DOMRenderer().render( 433 |
434 | 435 | 436 | 437 | 438 | ); 439 | ``` 440 | 441 | ### Async component 442 | 443 | #### `AsyncTag.tsx` 444 | 445 | ```tsx 446 | import { FC } from 'web-cell'; 447 | 448 | const AsyncTag: FC = () =>
Async
; 449 | 450 | export default AsyncTag; 451 | ``` 452 | 453 | #### `index.tsx` 454 | 455 | ```tsx 456 | import { DOMRenderer } from 'dom-renderer'; 457 | import { lazy } from 'web-cell'; 458 | 459 | const AsyncTag = lazy(() => import('./AsyncTag')); 460 | 461 | new DOMRenderer().render(); 462 | ``` 463 | 464 | ### Async rendering (experimental) 465 | 466 | #### DOM tree 467 | 468 | ```tsx 469 | import { DOMRenderer } from 'dom-renderer'; 470 | 471 | new DOMRenderer().render( 472 | 473 | Async rendering 474 | , 475 | document.body, 476 | 'async' 477 | ); 478 | ``` 479 | 480 | #### Class component 481 | 482 | ```tsx 483 | import { component } from 'web-cell'; 484 | 485 | @component({ 486 | tagName: 'async-renderer', 487 | renderMode: 'async' 488 | }) 489 | export class AsyncRenderer extends HTMLElement { 490 | render() { 491 | return ( 492 | 493 | Async rendering 494 | 495 | ); 496 | } 497 | } 498 | ``` 499 | 500 | ### Animate CSS component 501 | 502 | ```tsx 503 | import { DOMRenderer } from 'dom-renderer'; 504 | import { AnimateCSS } from 'web-cell'; 505 | 506 | new DOMRenderer().render( 507 |

Fade In

} 510 | /> 511 | ); 512 | ``` 513 | 514 | ## Node.js usage 515 | 516 | ### Tool chain 517 | 518 | ```shell 519 | npm install jsdom 520 | ``` 521 | 522 | ### Polyfill 523 | 524 | ```js 525 | import 'web-cell/polyfill'; 526 | ``` 527 | 528 | ### Server Side Rendering 529 | 530 | https://github.com/EasyWebApp/DOM-Renderer?tab=readme-ov-file#nodejs--bun 531 | 532 | ## Basic knowledge 533 | 534 | - [Web components][23] 535 | - [Custom elements][24] 536 | - [Shadow DOM][25] 537 | - [Element Internals][26] 538 | - [CSS variables][27] 539 | - [View transitions][28] 540 | - [ECMAScript 6+][29] 541 | - [TypeScript 5+][4] 542 | 543 | ## Life Cycle hooks 544 | 545 | 1. [`connectedCallback`][30] 546 | 2. [`disconnectedCallback`][31] 547 | 3. [`attributeChangedCallback`][32] 548 | 4. [`adoptedCallback`][33] 549 | 5. [`updatedCallback`][34] 550 | 6. [`mountedCallback`][35] 551 | 7. [`formAssociatedCallback`][36] 552 | 8. [`formDisabledCallback`][37] 553 | 9. [`formResetCallback`][38] 554 | 10. [`formStateRestoreCallback`][39] 555 | 556 | ## Scaffolds 557 | 558 | 1. [Basic][22] 559 | 2. [DashBoard][40] 560 | 3. [Mobile][41] 561 | 4. [Static site][42] 562 | 563 | ## Ecosystem 564 | 565 | We recommend these libraries to use with WebCell: 566 | 567 | - **State management**: [MobX][3] (also powered by **TypeScript** & **Decorator**) 568 | - **Router**: [Cell Router][43] 569 | - **UI components** 570 | 571 | - [BootCell][44] (based on **BootStrap v5**) 572 | - [MDUI][45] (based on **Material Design v3**) 573 | - [GitHub Web Widget][46] 574 | 575 | - **HTTP request**: [KoAJAX][47] (based on **Koa**-like middlewares) 576 | - **Utility**: [Web utility][48] methods & types 577 | - **Event stream**: [Iterable Observer][49] (`Observable` proposal) 578 | - **MarkDown integration**: [Parcel MDX transformer][50] (**MDX** Compiler plugin) 579 | 580 | ## Roadmap 581 | 582 | - [x] [Server-side Render][51] 583 | - [x] [Async Component loading][52] 584 | 585 | ## [v2 to v3 migration](./guide/Migrating.md) 586 | 587 | ## More guides 588 | 589 | 1. [Development contribution](./guide/Contributing.md) 590 | 591 | [1]: https://www.webcomponents.org/ 592 | [2]: https://facebook.github.io/jsx/ 593 | [3]: https://mobx.js.org/ 594 | [4]: https://www.typescriptlang.org/ 595 | [5]: https://libraries.io/npm/web-cell 596 | [6]: https://github.com/EasyWebApp/WebCell/actions/workflows/main.yml 597 | [7]: https://github.com/996icu/996.ICU/blob/master/LICENSE 598 | [8]: https://github.com/jaywcjlove/awesome-uikit 599 | [9]: https://tech-query.me/programming/web-components-practise/slide.html 600 | [10]: https://gitter.im/EasyWebApp/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge 601 | [11]: https://codesandbox.io/p/devbox/9gyll?embed=1&file=%2Fsrc%2FClock.tsx 602 | [12]: https://nodei.co/npm/web-cell/ 603 | [13]: https://www.typescriptlang.org/ 604 | [14]: https://github.com/tc39/proposal-decorators 605 | [15]: https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html 606 | [16]: https://www.webcomponents.org/ 607 | [17]: https://github.com/EasyWebApp/DOM-Renderer 608 | [18]: https://mobx.js.org/observable-state.html#observable 609 | [19]: https://mobx.js.org/enabling-decorators.html 610 | [20]: https://facebook.github.io/jsx/ 611 | [21]: https://parceljs.org/ 612 | [22]: https://github.com/EasyWebApp/WebCell-scaffold 613 | [23]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components 614 | [24]: https://web.dev/articles/custom-elements-v1 615 | [25]: https://web.dev/articles/shadowdom-v1 616 | [26]: https://web.dev/articles/more-capable-form-controls 617 | [27]: https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties 618 | [28]: https://developer.chrome.com/docs/web-platform/view-transitions/ 619 | [29]: https://rse.github.io/es6-features/ 620 | [30]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#connectedCallback 621 | [31]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#disconnectedCallback 622 | [32]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#attributeChangedCallback 623 | [33]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#adoptedCallback 624 | [34]: https://web-cell.dev/WebCell/interfaces/WebCell.html#updatedCallback 625 | [35]: https://web-cell.dev/WebCell/interfaces/WebCell.html#mountedCallback 626 | [36]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formAssociatedCallback 627 | [37]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formDisabledCallback 628 | [38]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formResetCallback 629 | [39]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formStateRestoreCallback 630 | [40]: https://github.com/EasyWebApp/WebCell-dashboard 631 | [41]: https://github.com/EasyWebApp/WebCell-mobile 632 | [42]: https://github.com/EasyWebApp/mark-wiki 633 | [43]: https://web-cell.dev/cell-router/ 634 | [44]: https://bootstrap.web-cell.dev/ 635 | [45]: https://www.mdui.org/ 636 | [46]: https://tech-query.me/GitHub-Web-Widget/ 637 | [47]: https://web-cell.dev/KoAJAX/ 638 | [48]: https://web-cell.dev/web-utility/ 639 | [49]: https://web-cell.dev/iterable-observer/ 640 | [50]: https://github.com/EasyWebApp/Parcel-transformer-MDX 641 | [51]: https://developer.chrome.com/docs/css-ui/declarative-shadow-dom 642 | [52]: https://legacy.reactjs.org/docs/react-api.html#reactlazy 643 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import cspellPlugin from '@cspell/eslint-plugin'; 2 | import eslint from '@eslint/js'; 3 | import stylistic from '@stylistic/eslint-plugin'; 4 | import eslintConfigPrettier from 'eslint-config-prettier'; 5 | import react from 'eslint-plugin-react'; 6 | import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; 7 | import globals from 'globals'; 8 | import tsEslint, { ConfigArray } from 'typescript-eslint'; 9 | import { fileURLToPath } from 'url'; 10 | 11 | /** 12 | * @see{@link https://github.com/typescript-eslint/typescript-eslint/blob/main/eslint.config.mjs} 13 | */ 14 | 15 | const tsconfigRootDir = fileURLToPath(new URL('.', import.meta.url)); 16 | 17 | const config: ConfigArray = tsEslint.config( 18 | // register all of the plugins up-front 19 | { 20 | plugins: { 21 | '@typescript-eslint': tsEslint.plugin, 22 | react, 23 | '@stylistic': stylistic, 24 | 'simple-import-sort': simpleImportSortPlugin, 25 | '@cspell': cspellPlugin 26 | } 27 | }, 28 | { 29 | // config with just ignores is the replacement for `.eslintignore` 30 | ignores: [ 31 | '**/node_modules/**', 32 | 'dist/**', 33 | '.parcel-cache/**', 34 | 'docs/**' 35 | ] 36 | }, 37 | 38 | // extends ... 39 | eslint.configs.recommended, 40 | ...tsEslint.configs.recommended, 41 | 42 | // base config 43 | { 44 | languageOptions: { 45 | globals: { ...globals.es2020, ...globals.browser, ...globals.node }, 46 | parserOptions: { 47 | projectService: true, 48 | tsconfigRootDir, 49 | warnOnUnsupportedTypeScriptVersion: false 50 | } 51 | }, 52 | rules: { 53 | 'arrow-body-style': ['error', 'as-needed'], 54 | 'no-empty-pattern': 'warn', 55 | 'no-console': ['error', { allow: ['warn', 'error', 'info'] }], 56 | 'consistent-return': 'warn', 57 | 'prefer-destructuring': ['error', { object: true, array: true }], 58 | 59 | // react 60 | 'react/no-unescaped-entities': 'off', 61 | 'react/self-closing-comp': [ 62 | 'error', 63 | { component: true, html: true } 64 | ], 65 | 'react/jsx-curly-brace-presence': [ 66 | 'error', 67 | { props: 'never', children: 'never' } 68 | ], 69 | 'react/jsx-no-target-blank': 'warn', 70 | 'react/jsx-sort-props': [ 71 | 'error', 72 | { 73 | reservedFirst: true, 74 | callbacksLast: true, 75 | noSortAlphabetically: true 76 | } 77 | ], 78 | // typescript 79 | '@typescript-eslint/no-unused-vars': 'warn', 80 | '@typescript-eslint/no-explicit-any': 'warn', 81 | '@typescript-eslint/no-empty-object-type': 'off', 82 | '@typescript-eslint/no-unsafe-declaration-merging': 'warn', 83 | 84 | // stylistic 85 | '@stylistic/padding-line-between-statements': [ 86 | 'error', 87 | { blankLine: 'always', prev: '*', next: 'return' }, 88 | { blankLine: 'always', prev: 'directive', next: '*' }, 89 | { blankLine: 'any', prev: 'directive', next: 'directive' }, 90 | { 91 | blankLine: 'always', 92 | prev: '*', 93 | next: ['enum', 'interface', 'type'] 94 | } 95 | ], 96 | 97 | // simple-import-sort 98 | 'simple-import-sort/exports': 'error', 99 | 'simple-import-sort/imports': 'error', 100 | // spellchecker 101 | '@cspell/spellchecker': [ 102 | 'warn', 103 | { 104 | cspell: { 105 | language: 'en', 106 | dictionaries: [ 107 | 'typescript', 108 | 'node', 109 | 'html', 110 | 'css', 111 | 'bash', 112 | 'npm', 113 | 'pnpm' 114 | ] 115 | } 116 | } 117 | ] 118 | } 119 | }, 120 | eslintConfigPrettier 121 | ); 122 | 123 | export default config; 124 | -------------------------------------------------------------------------------- /guide/Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributor guide 2 | 3 | ## Local development 4 | 5 | ```shell 6 | git clone https://github.com/EasyWebApp/WebCell.git ~/Desktop/WebCell 7 | cd ~/Desktop/WebCell 8 | 9 | npm i pnpm -g 10 | pnpm i 11 | npm test 12 | pnpm build 13 | ``` 14 | -------------------------------------------------------------------------------- /guide/Migrating-zh.md: -------------------------------------------------------------------------------- 1 | # WebCell 从 v2 到 v3 的迁移 2 | 3 | ## 类 React 状态管理已被完全移除 4 | 5 | **WebCell v3** 受到了 [**MobX** 的 **本地可观察状态** 思想][1] 的深刻启发,[不仅仅是 React][2],Web Components 可以更轻松地管理 **内部状态和逻辑**,无需任何复杂的操作: 6 | 7 | 1. 状态类型声明 8 | 2. `this.state` 声明及其类型注解/断言 9 | 3. `this.setState()` 方法的调用及其回调 10 | 4. 令人困惑的 _Hooks API_... 11 | 12 | 只需像管理 **全局状态** 一样声明一个 **状态存储类**,并在 `this`(即 **Web Component 实例**)上初始化它。然后像 [MobX][3] 一样使用并观察这些状态,一切就完成了。 13 | 14 | ```diff 15 | import { 16 | component, 17 | + observer, 18 | - mixin, 19 | - createCell, 20 | - Fragment 21 | } from 'web-cell'; 22 | +import { observable } from 'mobx'; 23 | 24 | -interface State { 25 | +class State { 26 | + @observable 27 | - key: string; 28 | + accessor key = ''; 29 | } 30 | 31 | @component({ 32 | tagName: 'my-tag' 33 | }) 34 | +@observer 35 | -export class MyTag extends mixin<{}, State>() { 36 | +export class MyTag extends HTMLElement { 37 | - state: Readonly = { 38 | - key: 'value' 39 | - }; 40 | + state = new State(); 41 | 42 | - render({}: any, { key }: State) { 43 | + render() { 44 | + const { key } = this.state; 45 | 46 | return <>{value}; 47 | } 48 | } 49 | ``` 50 | 51 | 同时,`shouldUpdate() {}` 生命周期方法已被移除。你只需在 `State` 类的方法中,在状态改变之前控制逻辑即可。 52 | 53 | ## DOM 属性变为可观察数据 54 | 55 | **DOM 属性** 不同于 React 的 props,它们是 **响应式的**。它们不仅负责 **更新组件视图**,还会与 **HTML 属性同步**。 56 | 57 | MobX 的 [`@observable`][4] 和 [`reaction()`][5] 是实现上述功能的优秀 API,代码也非常清晰,因此我们添加了 `mobx` 包作为依赖: 58 | 59 | ```shell 60 | npm install mobx 61 | ``` 62 | 63 | 另一方面,[`mobx-web-cell` 适配器][6] 已经合并到了核心包中。 64 | 65 | ```diff 66 | +import { JsxProps } from 'dom-renderer'; 67 | import { 68 | - WebCellProps, 69 | component, 70 | attribute, 71 | - watch, 72 | + observer, 73 | - mixin, 74 | - createCell, 75 | - Fragment 76 | } from 'web-cell'; 77 | -import { observer } from 'mobx-web-cell'; 78 | +import { observable } from 'mobx'; 79 | 80 | -export interface MyTagProps extends WebCellProps { 81 | +export interface MyTagProps extends JsxProps { 82 | count?: number 83 | } 84 | 85 | @component({ 86 | tagName: 'my-tag' 87 | }) 88 | @observer 89 | -export class MyTag extends mixin() { 90 | +export class MyTag extends HTMLElement { 91 | + declare props: MyTagProps; 92 | 93 | @attribute 94 | - @watch 95 | + @observable 96 | - count = 0; 97 | + accessor count = 0; 98 | 99 | - render({ count }: MyTagProps) { 100 | + render() { 101 | + const { count } = this; 102 | 103 | return <>{count}; 104 | } 105 | } 106 | ``` 107 | 108 | ## 使用 Shadow DOM 的 `mode` 选项控制渲染目标 109 | 110 | ### 渲染到 `children` 111 | 112 | ```diff 113 | import { 114 | component, 115 | - mixin 116 | } from 'web-cell'; 117 | 118 | @component({ 119 | tagName: 'my-tag', 120 | - renderTarget: 'children' 121 | }) 122 | -export class MyTag extends mixin() { 123 | +export class MyTag extends HTMLElement { 124 | } 125 | ``` 126 | 127 | ### 渲染到 `shadowRoot` 128 | 129 | ```diff 130 | import { 131 | component, 132 | - mixin 133 | } from 'web-cell'; 134 | 135 | @component({ 136 | tagName: 'my-tag', 137 | - renderTarget: 'shadowRoot' 138 | + mode: 'open' 139 | }) 140 | -export class MyTag extends mixin() { 141 | +export class MyTag extends HTMLElement { 142 | } 143 | ``` 144 | 145 | ## 将 Shadow CSS 注入移动到 `render()` 146 | 147 | 这样使得 **Shadow CSS** 可以随着可观察数据的更新而响应。 148 | 149 | ```diff 150 | +import { stringifyCSS } from 'web-utility'; 151 | import { 152 | component, 153 | - mixin 154 | } from 'web-cell'; 155 | 156 | @component({ 157 | tagName: 'my-tag', 158 | - renderTarget: 'shadowRoot', 159 | + mode: 'open', 160 | - style: { 161 | - ':host(.active)': { 162 | - color: 'red' 163 | - } 164 | - } 165 | }) 166 | -export class MyTag extends mixin() { 167 | +export class MyTag extends HTMLElement { 168 | render() { 169 | return <> 170 | + 177 | test 178 | ; 179 | } 180 | } 181 | ``` 182 | 183 | ## 替换部分 API 184 | 185 | 1. `mixin()` => `HTMLElement` 及其子类 186 | 2. `mixinForm()` => `HTMLElement` 和 `@formField` 187 | 3. `@watch` => `@observable accessor` 188 | 189 | ## 附录:v3 原型 190 | 191 | 1. [旧架构](https://codesandbox.io/s/web-components-jsx-i7u60?file=/index.tsx) 192 | 2. [现代架构](https://codesandbox.io/s/mobx-web-components-pvn9rf?file=/src/WebComponent.ts) 193 | 3. [MobX 精简版](https://codesandbox.io/s/mobx-lite-791eg?file=/src/index.ts) 194 | 195 | [1]: https://github.com/mobxjs/mobx/blob/mobx4and5/docs/refguide/observer-component.md#local-observable-state-in-class-based-components 196 | [2]: https://fcc-cd.dev/article/translation/3-reasons-why-i-stopped-using-react-setstate/ 197 | [3]: https://github.com/mobxjs/mobx/tree/mobx4and5/docs 198 | [4]: https://github.com/mobxjs/mobx/blob/mobx4and5/docs/refguide/observable-decorator.md 199 | [5]: https://github.com/mobxjs/mobx/blob/mobx4and5/docs/refguide/reaction.md 200 | [6]: https://github.com/EasyWebApp/WebCell/tree/v2/MobX 201 | -------------------------------------------------------------------------------- /guide/Migrating.md: -------------------------------------------------------------------------------- 1 | # WebCell v2 to v3 migration 2 | 3 | ## React-style State has been totally dropped 4 | 5 | **WebCell v3** is heavily inspired by [the **Local Observable State** idea of **MobX**][1], and [not only React][2], Web Components can be much easier to manage the **Inner State & Logic**, without any complex things: 6 | 7 | 1. State type declaration 8 | 2. `this.state` declaration & its type annotation/assertion 9 | 3. `this.setState()` method calling & its callback 10 | 4. confusive _Hooks API_... 11 | 12 | Just declare a **State Store class** as what the **Global State Managment** does, and initial it on the `this` (a **Web Component instance**). Then use the state, and observe them, as [MobX][3]'s usual, everything is done. 13 | 14 | ```diff 15 | import { 16 | component, 17 | + observer, 18 | - mixin, 19 | - createCell, 20 | - Fragment 21 | } from 'web-cell'; 22 | +import { observable } from 'mobx'; 23 | 24 | -interface State { 25 | +class State { 26 | + @observable 27 | - key: string; 28 | + accessor key = ''; 29 | } 30 | 31 | @component({ 32 | tagName: 'my-tag' 33 | }) 34 | +@observer 35 | -export class MyTag extends mixin<{}, State>() { 36 | +export class MyTag extends HTMLElement { 37 | - state: Readonly = { 38 | - key: 'value' 39 | - }; 40 | + state = new State(); 41 | 42 | - render({}: any, { key }: State) { 43 | + render() { 44 | + const { key } = this.state; 45 | 46 | return <>{value}; 47 | } 48 | } 49 | ``` 50 | 51 | At the same time, `shouldUpdate() {}` life-cycle has been dropped. You just need to control the logic before states changed in your `State` class methods. 52 | 53 | ## DOM properties become observable data 54 | 55 | **DOM properties** aren't like React's props, they're **reactive**. They are not only responsible to **update Component views**, but also **synchronize with HTML attriutes**. 56 | 57 | MobX's [`@observable`][4] & [`reaction()`][5] are awesome APIs to implement these above with clear codes, so we add `mobx` package as a dependency: 58 | 59 | ```shell 60 | npm install mobx 61 | ``` 62 | 63 | On the other hand, [`mobx-web-cell` adapter][6] has been merged into the core package. 64 | 65 | ```diff 66 | +import { JsxProps } from 'dom-renderer'; 67 | import { 68 | - WebCellProps, 69 | component, 70 | attribute, 71 | - watch, 72 | + observer, 73 | - mixin, 74 | - createCell, 75 | - Fragment 76 | } from 'web-cell'; 77 | -import { observer } from 'mobx-web-cell'; 78 | +import { observable } from 'mobx'; 79 | 80 | -export interface MyTagProps extends WebCellProps { 81 | +export interface MyTagProps extends JsxProps { 82 | count?: number 83 | } 84 | 85 | @component({ 86 | tagName: 'my-tag' 87 | }) 88 | @observer 89 | -export class MyTag extends mixin() { 90 | +export class MyTag extends HTMLElement { 91 | + declare props: MyTagProps; 92 | 93 | @attribute 94 | - @watch 95 | + @observable 96 | - count = 0; 97 | + accessor count = 0; 98 | 99 | - render({ count }: MyTagProps) { 100 | + render() { 101 | + const { count } = this; 102 | 103 | return <>{count}; 104 | } 105 | } 106 | ``` 107 | 108 | ## control Render Target with Shadow DOM `mode` option 109 | 110 | ### render to `children` 111 | 112 | ```diff 113 | import { 114 | component, 115 | - mixin 116 | } from 'web-cell'; 117 | 118 | @component({ 119 | tagName: 'my-tag', 120 | - renderTarget: 'children' 121 | }) 122 | -export class MyTag extends mixin() { 123 | +export class MyTag extends HTMLElement { 124 | } 125 | ``` 126 | 127 | ### render to `shadowRoot` 128 | 129 | ```diff 130 | import { 131 | component, 132 | - mixin 133 | } from 'web-cell'; 134 | 135 | @component({ 136 | tagName: 'my-tag', 137 | - renderTarget: 'shadowRoot' 138 | + mode: 'open' 139 | }) 140 | -export class MyTag extends mixin() { 141 | +export class MyTag extends HTMLElement { 142 | } 143 | ``` 144 | 145 | ## move Shadow CSS injection into `render()` 146 | 147 | This makes **Shadow CSS** to react with Observable Data updating. 148 | 149 | ```diff 150 | +import { stringifyCSS } from 'web-utility'; 151 | import { 152 | component, 153 | - mixin 154 | } from 'web-cell'; 155 | 156 | @component({ 157 | tagName: 'my-tag', 158 | - renderTarget: 'shadowRoot', 159 | + mode: 'open', 160 | - style: { 161 | - ':host(.active)': { 162 | - color: 'red' 163 | - } 164 | - } 165 | }) 166 | -export class MyTag extends mixin() { 167 | +export class MyTag extends HTMLElement { 168 | render() { 169 | return <> 170 | + 177 | test 178 | ; 179 | } 180 | } 181 | ``` 182 | 183 | ## replace some APIs 184 | 185 | 1. `mixin()` => `HTMLElement` & its Sub-classes 186 | 2. `mixinForm()` => `HTMLElement` & `@formField` 187 | 3. `@watch` => `@observable accessor` 188 | 189 | ## Appendix: v3 prototype 190 | 191 | 1. [Legacy architecture](https://codesandbox.io/s/web-components-jsx-i7u60?file=/index.tsx) 192 | 2. [Modern architecture](https://codesandbox.io/s/mobx-web-components-pvn9rf?file=/src/WebComponent.ts) 193 | 3. [MobX lite](https://codesandbox.io/s/mobx-lite-791eg?file=/src/index.ts) 194 | 195 | [1]: https://github.com/mobxjs/mobx/blob/mobx4and5/docs/refguide/observer-component.md#local-observable-state-in-class-based-components 196 | [2]: https://blog.cloudboost.io/3-reasons-why-i-stopped-using-react-setstate-ab73fc67a42e 197 | [3]: https://github.com/mobxjs/mobx/tree/mobx4and5/docs 198 | [4]: https://github.com/mobxjs/mobx/blob/mobx4and5/docs/refguide/observable-decorator.md 199 | [5]: https://github.com/mobxjs/mobx/blob/mobx4and5/docs/refguide/reaction.md 200 | [6]: https://github.com/EasyWebApp/WebCell/tree/v2/MobX 201 | -------------------------------------------------------------------------------- /guide/ReadMe-zh.md: -------------------------------------------------------------------------------- 1 | # WebCell 2 | 3 | ![WebCell logo](https://web-cell.dev/WebCell-0.f9823b00.png) 4 | 5 | 简体中文 | [English](./) 6 | 7 | 基于 VDOM、[JSX][2]、[MobX][3] 和 [TypeScript][4] 的 [Web 组件][1] 引擎 8 | 9 | [![NPM 依赖性](https://img.shields.io/librariesio/github/EasyWebApp/WebCell.svg)][5] 10 | [![CI 和 CD](https://github.com/EasyWebApp/WebCell/actions/workflows/main.yml/badge.svg)][6] 11 | 12 | [![反 996 许可证](https://img.shields.io/badge/license-Anti%20996-blue.svg)][7] 13 | [![UI 库推荐榜单](https://jaywcjlove.github.io/sb/ico/awesome.svg)][8] 14 | 15 | [![幻灯片](https://img.shields.io/badge/learn-Slideshow-blue)][9] 16 | [![Gitter](https://badges.gitter.im/EasyWebApp/community.svg)][10] 17 | 18 | [![编辑 WebCell 示例](https://codesandbox.io/static/img/play-codesandbox.svg)][11] 19 | 20 | [![NPM](https://nodei.co/npm/web-cell.png?downloads=true&downloadRank=true&stars=true)][12] 21 | 22 | ## 特性 23 | 24 | ### 引擎比较 25 | 26 | | 特性 | WebCell 3 | WebCell 2 | React | Vue | 27 | | :----------: | :----------------------: | :------------------: | :---------------------------: | :------------------------------: | 28 | | JS 语言 | [TypeScript 5][13] | TypeScript 4 | ECMAScript 或 TypeScript | ECMAScript 或 TypeScript | 29 | | JS 语法 | [ES 装饰器 stage-3][14] | ES 装饰器 stage-2 | | | 30 | | XML 语法 | [JSX import][15] | JSX factory | JSX factory/import | HTML/Vue 模板或 JSX(可选) | 31 | | DOM API | [Web 组件][16] | Web 组件 | HTML 5+ | HTML 5+ | 32 | | 视图渲染器 | [DOM Renderer 2][17] | SnabbDOM | (内置) | SnabbDOM(分叉) | 33 | | state API | [MobX `@observable`][18] | `this.state` | `this.state` 或 `useState()` | `this.$data` 或 `ref()` | 34 | | props API | MobX `@observable` | `@watch` | `this.props` 或 `props => {}` | `this.$props` 或 `defineProps()` | 35 | | 状态管理 | [MobX 6+][19] | MobX 4/5 | Redux | VueX | 36 | | 页面路由器 | [JSX][20] 标签 | JSX 标签 + JSON 数据 | JSX 标签 | JSON 数据 | 37 | | 资源打包工具 | [Parcel 2][21] | Parcel 1 | webpack | Vite | 38 | 39 | ## 安装 40 | 41 | ```shell 42 | npm install dom-renderer mobx web-cell 43 | ``` 44 | 45 | ## Web 浏览器用法 46 | 47 | [演示和 **GitHub 模板**][22] 48 | 49 | ### 项目引导 50 | 51 | #### 工具链 52 | 53 | ```shell 54 | npm install parcel @parcel/config-default @parcel/transformer-typescript-tsc -D 55 | ``` 56 | 57 | #### `package.json` 58 | 59 | ```json 60 | { 61 | "scripts": { 62 | "start": "parcel source/index.html --open", 63 | "build": "parcel build source/index.html --public-url ." 64 | } 65 | } 66 | ``` 67 | 68 | #### `tsconfig.json` 69 | 70 | ```json 71 | { 72 | "compilerOptions": { 73 | "target": "ES6", 74 | "module": "ES2020", 75 | "moduleResolution": "Node", 76 | "useDefineForClassFields": true, 77 | "jsx": "react-jsx", 78 | "jsxImportSource": "dom-renderer" 79 | } 80 | } 81 | ``` 82 | 83 | #### `.parcelrc` 84 | 85 | ```json 86 | { 87 | "extends": "@parcel/config-default", 88 | "transformers": { 89 | "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"] 90 | } 91 | } 92 | ``` 93 | 94 | #### `source/index.html` 95 | 96 | ```html 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | ``` 105 | 106 | ### 函数组件 107 | 108 | ```tsx 109 | import { DOMRenderer } from 'dom-renderer'; 110 | import { FC, PropsWithChildren } from 'web-cell'; 111 | 112 | const Hello: FC = ({ children = '世界' }) => ( 113 |

你好,{children}!

114 | ); 115 | 116 | new DOMRenderer().render(WebCell); 117 | ``` 118 | 119 | ### 类组件 120 | 121 | #### 子元素插槽 122 | 123 | ```tsx 124 | import { DOMRenderer } from 'dom-renderer'; 125 | import { component } from 'web-cell'; 126 | 127 | @component({ 128 | tagName: 'hello-world', 129 | mode: 'open' 130 | }) 131 | class Hello extends HTMLElement { 132 | render() { 133 | return ( 134 |

135 | 你好, ! 136 |

137 | ); 138 | } 139 | } 140 | 141 | new DOMRenderer().render( 142 | <> 143 | WebCell 144 | {/* 或 */} 145 | WebCell 146 | 147 | ); 148 | ``` 149 | 150 | #### DOM 属性 151 | 152 | ```tsx 153 | import { DOMRenderer } from 'dom-renderer'; 154 | import { observable } from 'mobx'; 155 | import { WebCell, component, attribute, observer } from 'web-cell'; 156 | 157 | interface HelloProps { 158 | name?: string; 159 | } 160 | 161 | interface Hello extends WebCell {} 162 | 163 | @component({ tagName: 'hello-world' }) 164 | @observer 165 | class Hello extends HTMLElement implements WebCell { 166 | @attribute 167 | @observable 168 | accessor name = ''; 169 | 170 | render() { 171 | return

你好,{this.name}!

; 172 | } 173 | } 174 | 175 | new DOMRenderer().render(); 176 | 177 | // 或在 TypeScript 中提示 HTML 标签属性 178 | 179 | declare global { 180 | namespace JSX { 181 | interface IntrinsicElements { 182 | 'hello-world': HelloProps; 183 | } 184 | } 185 | } 186 | new DOMRenderer().render(); 187 | ``` 188 | 189 | ### 内部状态 190 | 191 | #### 函数组件 192 | 193 | ```tsx 194 | import { DOMRenderer } from 'dom-renderer'; 195 | import { observable } from 'mobx'; 196 | import { FC, observer } from 'web-cell'; 197 | 198 | class CounterModel { 199 | @observable 200 | accessor times = 0; 201 | } 202 | 203 | const couterStore = new CounterModel(); 204 | 205 | const Counter: FC = observer(() => ( 206 | 209 | )); 210 | 211 | new DOMRenderer().render(); 212 | ``` 213 | 214 | #### 类组件 215 | 216 | ```tsx 217 | import { DOMRenderer } from 'dom-renderer'; 218 | import { observable } from 'mobx'; 219 | import { component, observer } from 'web-cell'; 220 | 221 | @component({ tagName: 'my-counter' }) 222 | @observer 223 | class Counter extends HTMLElement { 224 | @observable 225 | accessor times = 0; 226 | 227 | handleClick = () => (this.times += 1); 228 | 229 | render() { 230 | return ; 231 | } 232 | } 233 | 234 | new DOMRenderer().render(); 235 | ``` 236 | 237 | ### CSS 作用域 238 | 239 | #### 内联样式 240 | 241 | ```tsx 242 | import { component } from 'web-cell'; 243 | import { stringifyCSS } from 'web-utility'; 244 | 245 | @component({ 246 | tagName: 'my-button', 247 | mode: 'open' 248 | }) 249 | export class MyButton extends HTMLElement { 250 | style = stringifyCSS({ 251 | '.btn': { 252 | color: 'white', 253 | background: 'lightblue' 254 | } 255 | }); 256 | 257 | render() { 258 | return ( 259 | <> 260 | 261 | 262 | 263 | 264 | 265 | 266 | ); 267 | } 268 | } 269 | ``` 270 | 271 | #### 链接样式表 272 | 273 | ```tsx 274 | import { component } from 'web-cell'; 275 | 276 | @component({ 277 | tagName: 'my-button', 278 | mode: 'open' 279 | }) 280 | export class MyButton extends HTMLElement { 281 | render() { 282 | return ( 283 | <> 284 | 288 | 289 | 290 | 291 | 292 | ); 293 | } 294 | } 295 | ``` 296 | 297 | #### CSS 模块 298 | 299 | ##### `scoped.css` 300 | 301 | ```css 302 | .btn { 303 | color: white; 304 | background: lightblue; 305 | } 306 | ``` 307 | 308 | ##### `MyButton.tsx` 309 | 310 | ```tsx 311 | import { WebCell, component } from 'web-cell'; 312 | 313 | import styles from './scoped.css' assert { type: 'css' }; 314 | 315 | interface MyButton extends WebCell {} 316 | 317 | @component({ 318 | tagName: 'my-button', 319 | mode: 'open' 320 | }) 321 | export class MyButton extends HTMLElement implements WebCell { 322 | connectedCallback() { 323 | this.root.adoptedStyleSheets = [styles]; 324 | } 325 | 326 | render() { 327 | return ( 328 | 329 | 330 | 331 | ); 332 | } 333 | } 334 | ``` 335 | 336 | ### 事件委托 337 | 338 | ```tsx 339 | import { component, on } from 'web-cell'; 340 | 341 | @component({ tagName: 'my-table' }) 342 | export class MyTable extends HTMLElement { 343 | @on('click', ':host td > button') 344 | handleEdit(event: MouseEvent, { dataset: { id } }: HTMLButtonElement) { 345 | console.log(`编辑行:${id}`); 346 | } 347 | 348 | render() { 349 | return ( 350 | 351 | 352 | 353 | 354 | 357 | 358 | 359 | 360 | 361 | 364 | 365 | 366 | 367 | 368 | 371 | 372 |
1A 355 | 356 |
2B 362 | 363 |
3C 369 | 370 |
373 | ); 374 | } 375 | } 376 | ``` 377 | 378 | ### MobX reaction 379 | 380 | ```tsx 381 | import { observable } from 'mobx'; 382 | import { component, observer, reaction } from 'web-cell'; 383 | 384 | @component({ tagName: 'my-counter' }) 385 | @observer 386 | export class Counter extends HTMLElement { 387 | @observable 388 | accessor times = 0; 389 | 390 | handleClick = () => (this.times += 1); 391 | 392 | @reaction(({ times }) => times) 393 | echoTimes(newValue: number, oldValue: number) { 394 | console.log(`新值:${newValue},旧值:${oldValue}`); 395 | } 396 | 397 | render() { 398 | return ; 399 | } 400 | } 401 | ``` 402 | 403 | ### 表单关联 404 | 405 | ```tsx 406 | import { DOMRenderer } from 'dom-renderer'; 407 | import { WebField, component, formField, observer } from 'web-cell'; 408 | 409 | interface MyField extends WebField {} 410 | 411 | @component({ 412 | tagName: 'my-field', 413 | mode: 'open' 414 | }) 415 | @formField 416 | @observer 417 | class MyField extends HTMLElement implements WebField { 418 | render() { 419 | const { name } = this; 420 | 421 | return ( 422 | 425 | (this.value = value) 426 | } 427 | /> 428 | ); 429 | } 430 | } 431 | 432 | new DOMRenderer().render( 433 |
434 | 435 | 436 | 437 | 438 | ); 439 | ``` 440 | 441 | ### 异步组件 442 | 443 | #### `AsyncTag.tsx` 444 | 445 | ```tsx 446 | import { FC } from 'web-cell'; 447 | 448 | const AsyncTag: FC = () =>
异步
; 449 | 450 | export default AsyncTag; 451 | ``` 452 | 453 | #### `index.tsx` 454 | 455 | ```tsx 456 | import { DOMRenderer } from 'dom-renderer'; 457 | import { lazy } from 'web-cell'; 458 | 459 | const AsyncTag = lazy(() => import('./AsyncTag')); 460 | 461 | new DOMRenderer().render(); 462 | ``` 463 | 464 | ### Animate CSS 组件 465 | 466 | ```tsx 467 | import { DOMRenderer } from 'dom-renderer'; 468 | import { AnimateCSS } from 'web-cell'; 469 | 470 | new DOMRenderer().render( 471 |

淡入

} /> 472 | ); 473 | ``` 474 | 475 | ## Node.js 用法 476 | 477 | ### 工具链 478 | 479 | ```shell 480 | npm install jsdom 481 | ``` 482 | 483 | ### Polyfill 484 | 485 | ```js 486 | import 'web-cell/polyfill'; 487 | ``` 488 | 489 | ### 服务端渲染 490 | 491 | https://github.com/EasyWebApp/DOM-Renderer?tab=readme-ov-file#nodejs--bun 492 | 493 | ## 基础知识 494 | 495 | - [Web 组件][23] 496 | - [自定义元素][24] 497 | - [虚拟 DOM][25] 498 | - [Element Internals][26] 499 | - [CSS 变量][27] 500 | - [视图渐变][28] 501 | - [ECMAScript 6+][29] 502 | - [TypeScript 5+][4] 503 | 504 | ## 生命周期钩子 505 | 506 | 1. [`connectedCallback`][30] 507 | 2. [`disconnectedCallback`][31] 508 | 3. [`attributeChangedCallback`][32] 509 | 4. [`adoptedCallback`][33] 510 | 5. [`updatedCallback`][34] 511 | 6. [`mountedCallback`][35] 512 | 7. [`formAssociatedCallback`][36] 513 | 8. [`formDisabledCallback`][37] 514 | 9. [`formResetCallback`][38] 515 | 10. [`formStateRestoreCallback`][39] 516 | 517 | ## 脚手架 518 | 519 | 1. [基础][22] 520 | 2. [仪表盘][40] 521 | 3. [移动端][41] 522 | 4. [静态网站][42] 523 | 524 | ## 生态系统 525 | 526 | 我们建议将这些库与 WebCell 一起使用: 527 | 528 | - **状态管理**:[MobX][3](也由 **TypeScript** 和 **Decorator** 提供支持) 529 | - **路由**:[Cell Router][43] 530 | - **UI 组件** 531 | 532 | - [BootCell][44](基于 **BootStrap v5**) 533 | - [MDUI][45](基于 **Material Design v3**) 534 | - [GitHub Web Widget][46] 535 | 536 | - **HTTP 请求**:[KoAJAX][47](基于类 **Koa** 中间件) 537 | - **实用程序**:[Web utility][48] 方法和类型 538 | - **事件流**:[Iterable Observer][49](`Observable` 提案) 539 | - **MarkDown 集成**:[Parcel MDX transformer][50](**MDX** 编译器插件) 540 | 541 | ## 路线图 542 | 543 | - [x] [服务器端渲染][51] 544 | - [x] [异步组件加载][52] 545 | 546 | ## [v2 到 v3 迁移](./Migrating-zh.md) 547 | 548 | ## 更多指南 549 | 550 | 1. [开发贡献](./Contributing.md) 551 | 552 | [1]: https://www.webcomponents.org/ 553 | [2]: https://facebook.github.io/jsx/ 554 | [3]: https://mobx.js.org/ 555 | [4]: https://www.typescriptlang.org/ 556 | [5]: https://libraries.io/npm/web-cell 557 | [6]: https://github.com/EasyWebApp/WebCell/actions/workflows/main.yml 558 | [7]: https://github.com/996icu/996.ICU/blob/master/LICENSE 559 | [8]: https://github.com/jaywcjlove/awesome-uikit 560 | [9]: https://tech-query.me/programming/web-components-practise/slide.html 561 | [10]: https://gitter.im/EasyWebApp/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge 562 | [11]: https://codesandbox.io/p/devbox/9gyll?embed=1&file=%2Fsrc%2FClock.tsx 563 | [12]: https://nodei.co/npm/web-cell/ 564 | [13]: https://www.typescriptlang.org/ 565 | [14]: https://github.com/tc39/proposal-decorators 566 | [15]: https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html 567 | [16]: https://www.webcomponents.org/ 568 | [17]: https://github.com/EasyWebApp/DOM-Renderer 569 | [18]: https://mobx.js.org/observable-state.html#observable 570 | [19]: https://mobx.js.org/enabling-decorators.html 571 | [20]: https://facebook.github.io/jsx/ 572 | [21]: https://parceljs.org/ 573 | [22]: https://github.com/EasyWebApp/WebCell-scaffold 574 | [23]: https://developer.mozilla.org/zh-CN/docs/Web/API/Web_components 575 | [24]: https://web.dev/articles/custom-elements-v1?hl=zh-cn 576 | [25]: https://web.dev/articles/shadowdom-v1?hl=zh-cn 577 | [26]: https://web.dev/articles/more-capable-form-controls?hl=zh-cn 578 | [27]: https://developer.mozilla.org/zh-CN/docs/Web/CSS/Using_CSS_custom_properties 579 | [28]: https://developer.chrome.com/docs/web-platform/view-transitions?hl=zh-cn 580 | [29]: https://rse.github.io/es6-features/ 581 | [30]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#connectedCallback 582 | [31]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#disconnectedCallback 583 | [32]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#attributeChangedCallback 584 | [33]: https://web-cell.dev/web-utility/interfaces/CustomElement.html#adoptedCallback 585 | [34]: https://web-cell.dev/WebCell/interfaces/WebCell.html#updatedCallback 586 | [35]: https://web-cell.dev/WebCell/interfaces/WebCell.html#mountedCallback 587 | [36]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formAssociatedCallback 588 | [37]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formDisabledCallback 589 | [38]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formResetCallback 590 | [39]: https://web-cell.dev/web-utility/interfaces/CustomFormElement.html#formStateRestoreCallback 591 | [40]: https://github.com/EasyWebApp/WebCell-dashboard 592 | [41]: https://github.com/EasyWebApp/WebCell-mobile 593 | [42]: https://github.com/EasyWebApp/mark-wiki 594 | [43]: https://web-cell.dev/cell-router/ 595 | [44]: https://bootstrap.web-cell.dev/ 596 | [45]: https://www.mdui.org/zh-cn/ 597 | [46]: https://tech-query.me/GitHub-Web-Widget/ 598 | [47]: https://web-cell.dev/KoAJAX/ 599 | [48]: https://web-cell.dev/web-utility/ 600 | [49]: https://web-cell.dev/iterable-observer/ 601 | [50]: https://github.com/EasyWebApp/Parcel-transformer-MDX 602 | [51]: https://developer.chrome.com/docs/css-ui/declarative-shadow-dom?hl=zh-cn 603 | [52]: https://legacy.reactjs.org/docs/react-api.html#reactlazy 604 | -------------------------------------------------------------------------------- /guide/pack-docs.sh: -------------------------------------------------------------------------------- 1 | mkdir -p dist/ 2 | cp ReadMe.md dist/ 3 | cp guide/*.md dist/ 4 | cd dist/ 5 | # remove Markdown suffix, because TypeDoc will copy Markdown file to `/media` folder 6 | replace ".md\)" ")" *.md 7 | replace "guide/" "" ReadMe.md 8 | 9 | # generate multilingual file 10 | for file in *.md; do 11 | typedoc --readme $file 12 | 13 | mv docs/index.html ./${file%.md}.html 14 | done 15 | 16 | cd ../ 17 | # generate docs 18 | typedoc source/ 19 | 20 | mv dist/*.html docs/ 21 | rm -r dist/docs dist/*.md 22 | 23 | cd docs 24 | # default language 25 | mv ReadMe.html index.html 26 | 27 | # replace ReadMe-* to *, change URL in *.html 28 | for file in ReadMe-*.html; do 29 | # example: mv ReadMe-zh.html docs/zh.html 30 | mv $file "${file#ReadMe-}" 31 | 32 | # example: remove ReadMe- 33 | replace "./ReadMe-" "./" *.html 34 | done 35 | -------------------------------------------------------------------------------- /guide/table.css: -------------------------------------------------------------------------------- 1 | /* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table#displaying_large_tables_in_small_spaces */ 2 | @media (max-width: 768px) { 3 | table { 4 | white-space: nowrap; 5 | } 6 | } 7 | 8 | table { 9 | width: 100%; 10 | margin: 0 auto; 11 | display: block; 12 | overflow-x: auto; 13 | border-spacing: 0; 14 | } 15 | 16 | th, 17 | td { 18 | border-top-width: 0; 19 | border-left-width: 0; 20 | padding: 0.25rem 0.5rem; 21 | } 22 | 23 | th { 24 | vertical-align: bottom; 25 | } 26 | 27 | th:last-child, 28 | td:last-child { 29 | border-right-width: 0; 30 | } 31 | 32 | tr:last-child td { 33 | border-bottom-width: 0; 34 | } 35 | 36 | tr td:first-child, 37 | tr th:first-child { 38 | position: sticky; 39 | left: 0; 40 | background: var(--color-background-secondary); 41 | z-index: 1; 42 | } 43 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const options: JestConfigWithTsJest = { 4 | testEnvironment: 'jsdom', 5 | preset: 'ts-jest', 6 | transform: { 7 | '.+\\.spec\\.tsx?$': ['ts-jest', { tsconfig: 'test/tsconfig.json' }] 8 | } 9 | }; 10 | export default options; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-cell", 3 | "version": "3.0.4", 4 | "description": "Web Components engine based on VDOM, JSX, MobX & TypeScript", 5 | "keywords": [ 6 | "web", 7 | "component", 8 | "engine", 9 | "vdom", 10 | "jsx", 11 | "mobx", 12 | "typescript" 13 | ], 14 | "license": "LGPL-3.0", 15 | "author": "shiy2008@gmail.com", 16 | "homepage": "https://web-cell.dev/", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/EasyWebApp/WebCell.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/EasyWebApp/WebCell/issues" 23 | }, 24 | "main": "dist/index.js", 25 | "module": "dist/index.esm.js", 26 | "source": "source/index.ts", 27 | "types": "dist/index.d.ts", 28 | "dependencies": { 29 | "@swc/helpers": "^0.5.15", 30 | "dom-renderer": "^2.6.2", 31 | "mobx": ">=6.13.6", 32 | "regenerator-runtime": "^0.14.1", 33 | "web-utility": "^4.4.3" 34 | }, 35 | "peerDependencies": { 36 | "@webcomponents/webcomponentsjs": "^2.8", 37 | "core-js": "^3", 38 | "jsdom": ">=23.1" 39 | }, 40 | "devDependencies": { 41 | "@cspell/eslint-plugin": "^8.17.5", 42 | "@eslint/js": "^9.22.0", 43 | "@parcel/config-default": "~2.13.3", 44 | "@parcel/packager-ts": "~2.13.3", 45 | "@parcel/transformer-typescript-tsc": "~2.13.3", 46 | "@parcel/transformer-typescript-types": "~2.13.3", 47 | "@stylistic/eslint-plugin": "^4.2.0", 48 | "@types/eslint-config-prettier": "^6.11.3", 49 | "@types/jest": "^29.5.14", 50 | "@types/node": "^22.13.10", 51 | "core-js": "^3.41.0", 52 | "element-internals-polyfill": "^1.3.13", 53 | "eslint": "^9.22.0", 54 | "eslint-config-prettier": "^10.1.1", 55 | "eslint-plugin-react": "^7.37.4", 56 | "eslint-plugin-simple-import-sort": "^12.1.1", 57 | "globals": "^16.0.0", 58 | "husky": "^9.1.7", 59 | "jest": "^29.7.0", 60 | "jest-environment-jsdom": "^29.7.0", 61 | "jiti": "^2.4.2", 62 | "jsdom": "^26.0.0", 63 | "lint-staged": "^15.5.0", 64 | "open-cli": "^8.0.0", 65 | "parcel": "~2.13.3", 66 | "prettier": "^3.5.3", 67 | "prettier-plugin-sh": "^0.15.0", 68 | "replace": "^1.2.2", 69 | "rimraf": "^6.0.1", 70 | "ts-jest": "^29.2.6", 71 | "ts-node": "^10.9.2", 72 | "typedoc": "^0.27.9", 73 | "typedoc-plugin-mdn-links": "^5.0.1", 74 | "typescript": "~5.8.2", 75 | "typescript-eslint": "^8.26.1" 76 | }, 77 | "scripts": { 78 | "prepare": "husky", 79 | "test": "lint-staged && jest", 80 | "clean": "rimraf .parcel-cache/ dist/ docs/", 81 | "preview": "npm run clean && cd preview/ && parcel --dist-dir=../docs/preview/ --open", 82 | "pack-preview": "rimraf .parcel-cache/ docs/preview/ && cd preview/ && parcel build --public-url=. --dist-dir=../docs/preview/", 83 | "pack-dist": "parcel build source/index.ts", 84 | "pack-docs": "sh ./guide/pack-docs.sh", 85 | "build": "npm run clean && npm run pack-dist && npm run pack-docs && npm run pack-preview", 86 | "start": "npm run pack-docs && open-cli docs/index.html", 87 | "prepublishOnly": "npm test && npm run build" 88 | }, 89 | "lint-staged": { 90 | "*.{md,json,yml,js,ts,tsx,sh}": "prettier --write", 91 | "*.{js,ts,tsx}": "eslint --fix" 92 | }, 93 | "prettier": { 94 | "singleQuote": true, 95 | "trailingComma": "none", 96 | "arrowParens": "avoid", 97 | "tabWidth": 4, 98 | "plugins": [ 99 | "prettier-plugin-sh" 100 | ] 101 | }, 102 | "browserslist": "> 0.5%, last 2 versions, not dead, IE 11", 103 | "targets": { 104 | "main": { 105 | "optimize": true 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /preview/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{ts,tsx}": [ 5 | "@parcel/transformer-typescript-tsc" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /preview/Async.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from '../source'; 2 | 3 | const Async: FC = ({ children }) => ( 4 |
Async load: {children}
5 | ); 6 | export default Async; 7 | -------------------------------------------------------------------------------- /preview/Clock.tsx: -------------------------------------------------------------------------------- 1 | import { IReactionPublic, observable } from 'mobx'; 2 | import { Second } from 'web-utility'; 3 | 4 | import { 5 | attribute, 6 | component, 7 | observer, 8 | on, 9 | reaction, 10 | WebCell 11 | } from '../source'; 12 | import { renderMode } from './utility'; 13 | 14 | class ClockModel { 15 | @observable 16 | accessor time = new Date(); 17 | 18 | constructor() { 19 | setInterval(() => (this.time = new Date()), Second); 20 | } 21 | } 22 | 23 | const clockStore = new ClockModel(); 24 | 25 | export const FunctionClock = observer(() => { 26 | const { time } = clockStore; 27 | 28 | return ( 29 | 32 | ); 33 | }); 34 | 35 | export interface ClassClock extends WebCell {} 36 | 37 | @component({ 38 | tagName: 'class-clock', 39 | mode: 'open', 40 | renderMode 41 | }) 42 | @observer 43 | export class ClassClock extends HTMLElement implements WebCell { 44 | @attribute 45 | @observable 46 | accessor time = new Date(); 47 | 48 | private timer: number; 49 | 50 | connectedCallback() { 51 | this.timer = window.setInterval(() => (this.time = new Date()), Second); 52 | } 53 | 54 | disconnectedCallback() { 55 | clearInterval(this.timer); 56 | } 57 | 58 | @reaction(({ time }) => time) 59 | handleReaction(newValue: Date, oldValue: Date, reaction: IReactionPublic) { 60 | console.info(newValue, oldValue, reaction); 61 | } 62 | 63 | @on('click', 'time') 64 | handleClick(event: MouseEvent, currentTarget: HTMLTimeElement) { 65 | console.info(event, currentTarget); 66 | } 67 | 68 | render() { 69 | const { time } = this; 70 | 71 | return ( 72 | 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /preview/Field.tsx: -------------------------------------------------------------------------------- 1 | import { component, formField, observer, WebField } from '../source'; 2 | 3 | export interface TestField extends WebField {} 4 | 5 | @component({ 6 | tagName: 'test-field', 7 | mode: 'open' 8 | }) 9 | @formField 10 | @observer 11 | export class TestField extends HTMLElement implements WebField { 12 | render() { 13 | const { name } = this; 14 | 15 | return ( 16 | 19 | (this.value = (currentTarget as HTMLInputElement).value) 20 | } 21 | /> 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /preview/Home.tsx: -------------------------------------------------------------------------------- 1 | import { formToJSON } from 'web-utility'; 2 | 3 | import { AnimateCSS, FC, lazy, WebCellProps } from '../source'; 4 | import { ClassClock, FunctionClock } from './Clock'; 5 | import { TestField } from './Field'; 6 | 7 | const Async = lazy(() => import('./Async')); 8 | 9 | const Hello: FC = ({ className, children }) => ( 10 |

Hello {children}!

11 | ); 12 | 13 | export const HomePage = () => ( 14 | <> 15 | WebCell} 18 | /> 19 |
20 | We use the same configuration as Parcel to bundle this sandbox, you 21 | can find more info about Parcel 22 | 27 | here 28 | 29 | . 30 |
31 | 32 |
    33 |
  • 34 | 35 |
  • 36 |
  • 37 | 38 |
  • 39 |
40 | 41 |
43 | alert(JSON.stringify(formToJSON(currentTarget))) 44 | } 45 | > 46 | 47 | 48 | 49 | 50 | 51 | content 52 | 53 | ); 54 | -------------------------------------------------------------------------------- /preview/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebCell preview 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /preview/index.tsx: -------------------------------------------------------------------------------- 1 | import { DOMRenderer } from 'dom-renderer'; 2 | import { configure } from 'mobx'; 3 | 4 | import { HomePage } from './Home'; 5 | import { renderMode } from './utility'; 6 | 7 | configure({ enforceActions: 'never' }); 8 | 9 | new DOMRenderer().render( 10 | , 11 | document.querySelector('#app')!, 12 | renderMode 13 | ); 14 | -------------------------------------------------------------------------------- /preview/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@easywebapp/web-cell-preview", 3 | "private": true, 4 | "source": "index.html" 5 | } 6 | -------------------------------------------------------------------------------- /preview/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ES2020", 5 | "moduleResolution": "Node", 6 | "useDefineForClassFields": true, 7 | "jsx": "react-jsx", 8 | "jsxImportSource": "dom-renderer" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /preview/utility.ts: -------------------------------------------------------------------------------- 1 | import { parseURLData } from 'web-utility'; 2 | 3 | export const { renderMode } = parseURLData() as { renderMode: 'sync' }; 4 | -------------------------------------------------------------------------------- /source/Animation/index.tsx: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | import { importCSS } from 'web-utility'; 3 | 4 | import { attribute, FC, observer, reaction } from '../decorator'; 5 | import { animated } from '../utility'; 6 | import { component, WebCell, WebCellProps } from '../WebCell'; 7 | import { AnimationType } from './type'; 8 | 9 | export * from './type'; 10 | 11 | export interface AnimateCSSProps { 12 | type: AnimationType; 13 | component: FC; 14 | } 15 | 16 | export interface AnimateCSS extends WebCell {} 17 | 18 | @component({ tagName: 'animation-css' }) 19 | @observer 20 | export class AnimateCSS 21 | extends HTMLElement 22 | implements WebCell 23 | { 24 | @attribute 25 | @observable 26 | accessor type: AnimationType; 27 | 28 | @attribute 29 | @observable 30 | accessor playing = false; 31 | 32 | component: FC; 33 | 34 | async connectedCallback() { 35 | await importCSS('https://unpkg.com/animate.css@4/animate.min.css'); 36 | 37 | this.typeChanged(); 38 | } 39 | 40 | @reaction(({ type }) => type) 41 | async typeChanged() { 42 | this.playing = true; 43 | 44 | await animated(this, '.animate__animated'); 45 | 46 | this.playing = false; 47 | } 48 | 49 | render() { 50 | const { type, playing, component: Tag } = this; 51 | 52 | return playing ? ( 53 | 54 | ) : type.includes('Out') ? ( 55 | <> 56 | ) : ( 57 | 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /source/Animation/type.ts: -------------------------------------------------------------------------------- 1 | export type PositionY = 'Top' | 'Bottom'; 2 | export type DirectionX = 'Left' | 'Right'; 3 | export type DirectionY = 'Up' | 'Down'; 4 | export type Direction = DirectionX | DirectionY; 5 | export type AnimationMode = 'In' | 'Out'; 6 | 7 | export type AttentionSeekers = 8 | | 'bounce' 9 | | 'flash' 10 | | 'pulse' 11 | | 'rubberBand' 12 | | `shake${'X' | 'Y'}` 13 | | 'headShake' 14 | | 'swing' 15 | | 'tada' 16 | | 'wobble' 17 | | 'jello' 18 | | 'heartBeat'; 19 | export type BackEntrances = `backIn${Direction}`; 20 | export type BackExits = `backOut${Direction}`; 21 | export type BouncingEntrances = `bounceIn${'' | Direction}`; 22 | export type BouncingExits = `bounceOut${'' | Direction}`; 23 | export type FadingEntrances = 24 | | `fadeIn${'' | `${Direction}${'' | 'Big'}`}` 25 | | `fadeIn${PositionY}${DirectionX}`; 26 | export type FadingExits = `fadeOut${ 27 | | '' 28 | | `${Direction}${'' | 'Big'}` 29 | | `${PositionY}${DirectionX}`}`; 30 | export type Flippers = `flip${'' | `${AnimationMode}${'X' | 'Y'}`}`; 31 | export type Lightspeed = `lightSpeed${AnimationMode}${DirectionX}`; 32 | export type RotatingEntrances = `rotateIn${'' | `${DirectionY}${DirectionX}`}`; 33 | export type RotatingExits = `rotateOut${'' | `${DirectionY}${DirectionX}`}`; 34 | export type Specials = 'hinge' | 'jackInTheBox' | `roll${'In' | 'Out'}`; 35 | export type ZoomingEntrances = `zoomIn${'' | Direction}`; 36 | export type ZoomingExits = `zoomOut${'' | Direction}`; 37 | export type SlidingEntrances = `slideIn${Direction}`; 38 | export type SlidingExits = `slideOut${Direction}`; 39 | 40 | export type AnimationType = 41 | | AttentionSeekers 42 | | BackEntrances 43 | | BackExits 44 | | BouncingEntrances 45 | | BouncingExits 46 | | FadingEntrances 47 | | FadingExits 48 | | Flippers 49 | | Lightspeed 50 | | RotatingEntrances 51 | | RotatingExits 52 | | Specials 53 | | ZoomingEntrances 54 | | ZoomingExits 55 | | SlidingEntrances 56 | | SlidingExits; 57 | -------------------------------------------------------------------------------- /source/Async.tsx: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | 3 | import { 4 | FC, 5 | FunctionComponent, 6 | observer, 7 | PropsWithChildren, 8 | WebCellComponent 9 | } from './decorator'; 10 | import { ClassComponent, component, WebCell, WebCellProps } from './WebCell'; 11 | 12 | export type ComponentTag = string | WebCellComponent; 13 | 14 | export interface AsyncCellProps { 15 | loader: () => Promise; 16 | delegatedProps?: WebCellProps; 17 | } 18 | 19 | export interface AsyncCell extends WebCell {} 20 | 21 | @component({ 22 | tagName: 'async-cell' 23 | }) 24 | @observer 25 | export class AsyncCell extends HTMLElement implements WebCell { 26 | loader: AsyncCellProps['loader']; 27 | 28 | @observable 29 | accessor component: FC; 30 | 31 | @observable 32 | accessor delegatedProps: AsyncCellProps['delegatedProps']; 33 | 34 | connectedCallback() { 35 | this.load(); 36 | } 37 | 38 | protected async load() { 39 | this.component = undefined; 40 | 41 | const Tag = await this.loader(); 42 | 43 | this.component = ({ children, ...props }) => ( 44 | {children} 45 | ); 46 | this.emit('load', this.component); 47 | } 48 | 49 | render() { 50 | const { component: Tag, props, delegatedProps } = this; 51 | const { children, ...data } = { ...props, ...delegatedProps }; 52 | 53 | return Tag && {children}; 54 | } 55 | } 56 | 57 | type GetAsyncProps = T extends () => Promise<{ 58 | default: FunctionComponent | ClassComponent; 59 | }> 60 | ? P 61 | : {}; 62 | 63 | export function lazy< 64 | T extends () => Promise<{ default: FunctionComponent | ClassComponent }> 65 | >(loader: T) { 66 | return (props: GetAsyncProps) => ( 67 | (await loader()).default} 70 | /> 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /source/WebCell.tsx: -------------------------------------------------------------------------------- 1 | import { DOMRenderer, JsxProps, RenderMode, VNode } from 'dom-renderer'; 2 | import { 3 | CustomElement, 4 | delegate, 5 | DelegateEventHandler, 6 | isEmpty 7 | } from 'web-utility'; 8 | 9 | export interface ComponentMeta 10 | extends ElementDefinitionOptions, 11 | Partial { 12 | tagName: string; 13 | transitible?: boolean; 14 | renderMode?: RenderMode; 15 | } 16 | 17 | export type ClassComponent = CustomElementConstructor; 18 | 19 | export type WebCellProps = JsxProps; 20 | 21 | export interface WebCell

extends CustomElement { 22 | props: P & WebCellProps; 23 | internals: ReturnType; 24 | renderer: DOMRenderer; 25 | root: ParentNode; 26 | mounted: boolean; 27 | update: () => Promise; 28 | /** 29 | * Called at DOM tree updated 30 | */ 31 | updatedCallback?: () => any; 32 | /** 33 | * Called at first time of DOM tree updated 34 | */ 35 | mountedCallback?: () => any; 36 | emit: (event: string, detail?: any, option?: EventInit) => boolean; 37 | } 38 | 39 | interface DelegatedEvent { 40 | type: keyof HTMLElementEventMap; 41 | selector: string; 42 | handler: EventListener; 43 | } 44 | const eventMap = new WeakMap(); 45 | 46 | /** 47 | * `class` decorator of Web components 48 | */ 49 | export function component(meta: ComponentMeta) { 50 | return ( 51 | Class: T, 52 | { addInitializer }: ClassDecoratorContext 53 | ) => { 54 | class RendererComponent 55 | extends (Class as ClassComponent) 56 | implements WebCell 57 | { 58 | declare props: WebCellProps; 59 | 60 | internals = this.tagName.includes('-') 61 | ? this.attachInternals() 62 | : undefined; 63 | renderer = new DOMRenderer(); 64 | 65 | get root(): ParentNode { 66 | return this.shadowRoot || this.internals.shadowRoot || this; 67 | } 68 | mounted = false; 69 | declare mountedCallback?: () => any; 70 | 71 | constructor() { 72 | super(); 73 | 74 | if (meta.mode && !this.internals?.shadowRoot) 75 | this.attachShadow(meta as ShadowRootInit); 76 | } 77 | 78 | async connectedCallback() { 79 | const { mode } = meta; 80 | const renderChildren = !(mode != null); 81 | 82 | const { root } = this, 83 | events = eventMap.get(this) || []; 84 | 85 | for (const { type, selector, handler } of events) { 86 | if (renderChildren && /^:host/.test(selector)) 87 | console.warn( 88 | `[WebCell] DOM Event delegation of "${selector}" won't work if you don't invoke "this.attachShadow()" manually.` 89 | ); 90 | root.addEventListener(type, handler); 91 | } 92 | 93 | super['connectedCallback']?.(); 94 | 95 | if (this.mounted) return; 96 | 97 | await this.update(); 98 | 99 | this.mounted = true; 100 | this.mountedCallback?.(); 101 | } 102 | 103 | declare render?: () => VNode; 104 | declare updatedCallback?: () => any; 105 | 106 | protected updateDOM(content: VNode) { 107 | const result = this.renderer.render( 108 | content, 109 | this.root, 110 | meta.renderMode as 'async' 111 | ); 112 | 113 | return result instanceof Promise 114 | ? result.then(this.updatedCallback?.bind(this)) 115 | : this.updatedCallback?.(); 116 | } 117 | 118 | async update() { 119 | const vNode = this.render?.(); 120 | 121 | const content = isEmpty(vNode) ? ( 122 | meta.mode ? ( 123 | 124 | ) : null 125 | ) : ( 126 | vNode 127 | ); 128 | if (!(content != null)) return; 129 | 130 | if ( 131 | !meta.transitible || 132 | typeof document.startViewTransition !== 'function' 133 | ) 134 | return this.updateDOM(content); 135 | 136 | const { updateCallbackDone, finished } = 137 | document.startViewTransition(() => this.updateDOM(content)); 138 | 139 | try { 140 | await finished; 141 | } catch { 142 | return updateCallbackDone; 143 | } 144 | } 145 | 146 | disconnectedCallback() { 147 | const { root } = this, 148 | events = eventMap.get(this) || []; 149 | 150 | for (const { type, handler } of events) 151 | root.removeEventListener(type, handler); 152 | 153 | super['disconnectedCallback']?.(); 154 | } 155 | 156 | emit( 157 | event: string, 158 | detail?: any, 159 | { cancelable, bubbles, composed }: EventInit = {} 160 | ) { 161 | return this.dispatchEvent( 162 | new CustomEvent(event, { 163 | detail, 164 | cancelable, 165 | bubbles, 166 | composed 167 | }) 168 | ); 169 | } 170 | } 171 | 172 | addInitializer(function () { 173 | globalThis.customElements?.define(meta.tagName, this, meta); 174 | }); 175 | 176 | return RendererComponent as unknown as T; 177 | }; 178 | } 179 | 180 | /** 181 | * Method decorator of DOM Event delegation 182 | */ 183 | export function on( 184 | type: DelegatedEvent['type'], 185 | selector: string 186 | ) { 187 | return ( 188 | method: DelegateEventHandler, 189 | { addInitializer }: ClassMethodDecoratorContext 190 | ) => 191 | addInitializer(function () { 192 | const events = eventMap.get(this) || [], 193 | handler = delegate(selector, method.bind(this)); 194 | 195 | events.push({ type, selector, handler }); 196 | 197 | eventMap.set(this, events); 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /source/WebField.ts: -------------------------------------------------------------------------------- 1 | import { observable } from 'mobx'; 2 | import { CustomFormElement, HTMLFieldProps } from 'web-utility'; 3 | 4 | import { attribute, reaction } from './decorator'; 5 | import { ClassComponent, WebCell } from './WebCell'; 6 | 7 | export interface WebField

8 | extends CustomFormElement, 9 | WebCell

{} 10 | 11 | /** 12 | * `class` decorator of Form associated Web components 13 | */ 14 | export function formField( 15 | Class: T, 16 | _: ClassDecoratorContext 17 | ) { 18 | class FormFieldComponent 19 | extends (Class as ClassComponent) 20 | implements CustomFormElement 21 | { 22 | /** 23 | * Defined in {@link component} 24 | */ 25 | declare internals: ElementInternals; 26 | static formAssociated = true; 27 | 28 | @reaction(({ value }) => value) 29 | setValue(value: string) { 30 | this.internals.setFormValue(value); 31 | } 32 | 33 | formDisabledCallback(disabled: boolean) { 34 | this.disabled = disabled; 35 | } 36 | 37 | @attribute 38 | @observable 39 | accessor name: string; 40 | 41 | @observable 42 | accessor value: string; 43 | 44 | @attribute 45 | @observable 46 | accessor required: boolean; 47 | 48 | @attribute 49 | @observable 50 | accessor disabled: boolean; 51 | 52 | @attribute 53 | @observable 54 | accessor autofocus: boolean; 55 | 56 | set defaultValue(raw: string) { 57 | this.setAttribute('value', raw); 58 | 59 | this.value ??= raw; 60 | } 61 | 62 | get defaultValue() { 63 | return this.getAttribute('value'); 64 | } 65 | 66 | get form() { 67 | return this.internals.form; 68 | } 69 | get validity() { 70 | return this.internals.validity; 71 | } 72 | get validationMessage() { 73 | return this.internals.validationMessage; 74 | } 75 | get willValidate() { 76 | return this.internals.willValidate; 77 | } 78 | checkValidity() { 79 | return this.internals.checkValidity(); 80 | } 81 | reportValidity() { 82 | return this.internals.reportValidity(); 83 | } 84 | } 85 | 86 | return FormFieldComponent as unknown as T; 87 | } 88 | -------------------------------------------------------------------------------- /source/decorator.ts: -------------------------------------------------------------------------------- 1 | import { DataObject, DOMRenderer, JsxChildren, VNode } from 'dom-renderer'; 2 | import { 3 | autorun, 4 | IReactionDisposer, 5 | IReactionPublic, 6 | reaction as watch 7 | } from 'mobx'; 8 | import { 9 | CustomElement, 10 | isHTMLElementClass, 11 | parseJSON, 12 | toCamelCase, 13 | toHyphenCase 14 | } from 'web-utility'; 15 | 16 | import { getMobxData } from './utility'; 17 | import { ClassComponent } from './WebCell'; 18 | 19 | export type PropsWithChildren

= P & { 20 | children?: JsxChildren; 21 | }; 22 | export type FunctionComponent

= (props: P) => VNode; 23 | export type FC

= FunctionComponent

; 24 | 25 | function wrapFunction

(func: FC

) { 26 | const renderer = new DOMRenderer(); 27 | 28 | return (props: P) => { 29 | let tree = func(props), 30 | root: Node; 31 | 32 | if (!VNode.isFragment(tree)) { 33 | const disposer = autorun(() => { 34 | tree = func(props); 35 | 36 | if (tree && root) renderer.patch(VNode.fromDOM(root), tree); 37 | }); 38 | const { ref } = tree; 39 | 40 | tree.ref = node => { 41 | if (node) root = node; 42 | else disposer(); 43 | 44 | ref?.(node); 45 | }; 46 | } 47 | 48 | return tree; 49 | }; 50 | } 51 | 52 | interface ReactionItem { 53 | expression: ReactionExpression; 54 | effect: (...data: any[]) => any; 55 | } 56 | const reactionMap = new WeakMap(); 57 | 58 | function wrapClass(Component: T) { 59 | class ObserverComponent 60 | extends (Component as ClassComponent) 61 | implements CustomElement 62 | { 63 | static observedAttributes = []; 64 | 65 | protected disposers: IReactionDisposer[] = []; 66 | 67 | get props() { 68 | return getMobxData(this); 69 | } 70 | 71 | constructor() { 72 | super(); 73 | 74 | Promise.resolve().then(() => this.#boot()); 75 | } 76 | 77 | update = () => { 78 | const { update } = Object.getPrototypeOf(this); 79 | 80 | return new Promise(resolve => 81 | this.disposers.push( 82 | autorun(() => update.call(this).then(resolve)) 83 | ) 84 | ); 85 | }; 86 | 87 | #boot() { 88 | const names: string[] = 89 | this.constructor['observedAttributes'] || [], 90 | reactions = reactionMap.get(this) || []; 91 | 92 | this.disposers.push( 93 | ...names.map(name => autorun(() => this.syncPropAttr(name))), 94 | ...reactions.map(({ expression, effect }) => 95 | watch( 96 | reaction => expression(this, reaction), 97 | effect.bind(this) 98 | ) 99 | ) 100 | ); 101 | } 102 | 103 | disconnectedCallback() { 104 | for (const disposer of this.disposers) disposer(); 105 | 106 | this.disposers.length = 0; 107 | 108 | super['disconnectedCallback']?.(); 109 | } 110 | 111 | setAttribute(name: string, value: string) { 112 | const old = super.getAttribute(name), 113 | names: string[] = this.constructor['observedAttributes']; 114 | 115 | super.setAttribute(name, value); 116 | 117 | if (names.includes(name)) 118 | this.attributeChangedCallback(name, old, value); 119 | } 120 | 121 | attributeChangedCallback(name: string, old: string, value: string) { 122 | this[toCamelCase(name)] = parseJSON(value); 123 | 124 | super['attributeChangedCallback']?.(name, old, value); 125 | } 126 | 127 | syncPropAttr(name: string) { 128 | let value = this[toCamelCase(name)]; 129 | 130 | if (!(value != null) || value === false) 131 | return this.removeAttribute(name); 132 | 133 | value = value === true ? name : value; 134 | 135 | if (typeof value === 'object') { 136 | value = value.toJSON?.(); 137 | 138 | value = 139 | typeof value === 'object' ? JSON.stringify(value) : value; 140 | } 141 | super.setAttribute(name, value); 142 | } 143 | } 144 | 145 | return ObserverComponent as unknown as T; 146 | } 147 | 148 | export type WebCellComponent = FunctionComponent | ClassComponent; 149 | 150 | /** 151 | * `class` decorator of Web components for MobX 152 | */ 153 | export function observer( 154 | func: T, 155 | _: ClassDecoratorContext 156 | ): T; 157 | export function observer(func: T): T; 158 | export function observer( 159 | func: T, 160 | _?: ClassDecoratorContext 161 | ) { 162 | return isHTMLElementClass(func) ? wrapClass(func) : wrapFunction(func); 163 | } 164 | 165 | /** 166 | * `accessor` decorator of MobX `@observable` for HTML attributes 167 | */ 168 | export function attribute( 169 | _: ClassAccessorDecoratorTarget, 170 | { name, addInitializer }: ClassAccessorDecoratorContext 171 | ) { 172 | addInitializer(function () { 173 | const names: string[] = this.constructor['observedAttributes'], 174 | attribute = toHyphenCase(name.toString()); 175 | 176 | if (!names.includes(attribute)) names.push(attribute); 177 | }); 178 | } 179 | 180 | export type ReactionExpression = ( 181 | data: I, 182 | reaction: IReactionPublic 183 | ) => O; 184 | 185 | export type ReactionEffect = ( 186 | newValue: V, 187 | oldValue: V, 188 | reaction: IReactionPublic 189 | ) => any; 190 | 191 | /** 192 | * Method decorator of MobX `reaction()` 193 | */ 194 | export function reaction( 195 | expression: ReactionExpression 196 | ) { 197 | return ( 198 | effect: ReactionEffect, 199 | { addInitializer }: ClassMethodDecoratorContext 200 | ) => 201 | addInitializer(function () { 202 | const reactions = reactionMap.get(this) || []; 203 | 204 | reactions.push({ expression, effect }); 205 | 206 | reactionMap.set(this, reactions); 207 | }); 208 | } 209 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Animation'; 2 | export * from './Async'; 3 | export * from './decorator'; 4 | export * from './utility'; 5 | export * from './WebCell'; 6 | export * from './WebField'; 7 | -------------------------------------------------------------------------------- /source/polyfill.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | 3 | const { window } = new JSDOM(); 4 | 5 | for (const key of [ 6 | 'self', 7 | 'document', 8 | 'customElements', 9 | 'HTMLElement', 10 | 'HTMLUnknownElement', 11 | 'XMLSerializer', 12 | 'CustomEvent' 13 | ]) 14 | globalThis[key] = window[key]; 15 | 16 | self.requestAnimationFrame = setTimeout; 17 | -------------------------------------------------------------------------------- /source/utility.ts: -------------------------------------------------------------------------------- 1 | import { DataObject } from 'dom-renderer'; 2 | import { ObservableValue } from 'mobx/dist/internal'; 3 | import { delegate } from 'web-utility'; 4 | 5 | export class Defer { 6 | resolve: (value: T | PromiseLike) => void; 7 | reject: (reason?: any) => void; 8 | 9 | promise = new Promise((resolve, reject) => { 10 | this.resolve = resolve; 11 | this.reject = reject; 12 | }); 13 | } 14 | 15 | export function getMobxData(observable: T) { 16 | for (const key of Object.getOwnPropertySymbols(observable)) { 17 | const store = observable[key as keyof T]?.values_ as Map< 18 | string, 19 | ObservableValue 20 | >; 21 | if (store instanceof Map) 22 | return Object.fromEntries( 23 | Array.from(store, ([key, { value_ }]) => [key, value_]) 24 | ) as T; 25 | } 26 | } 27 | 28 | export const animated = ( 29 | root: T, 30 | targetSelector: string 31 | ) => 32 | new Promise(resolve => { 33 | const ended = delegate(targetSelector, (event: AnimationEvent) => { 34 | root.removeEventListener('animationend', ended); 35 | root.removeEventListener('animationcancel', ended); 36 | resolve(event); 37 | }); 38 | 39 | root.addEventListener('animationend', ended); 40 | root.addEventListener('animationcancel', ended); 41 | }); 42 | -------------------------------------------------------------------------------- /test/Async.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'element-internals-polyfill'; 2 | 3 | import { DOMRenderer } from 'dom-renderer'; 4 | import { configure } from 'mobx'; 5 | import { sleep } from 'web-utility'; 6 | 7 | import { lazy } from '../source/Async'; 8 | import { FC } from '../source/decorator'; 9 | import { WebCellProps } from '../source/WebCell'; 10 | 11 | configure({ enforceActions: 'never' }); 12 | 13 | describe('Async Box component', () => { 14 | const renderer = new DOMRenderer(); 15 | 16 | it('should render an Async Component', async () => { 17 | const Sync: FC> = ({ 18 | children, 19 | ...props 20 | }) => {children}; 21 | 22 | const Async = lazy(async () => ({ default: Sync })); 23 | 24 | renderer.render(Test); 25 | 26 | expect(document.body.innerHTML).toBe(''); 27 | 28 | await sleep(); 29 | 30 | expect(document.body.innerHTML).toBe( 31 | 'Test' 32 | ); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/MobX.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'element-internals-polyfill'; 2 | 3 | import { DOMRenderer } from 'dom-renderer'; 4 | import { configure, observable } from 'mobx'; 5 | import { sleep } from 'web-utility'; 6 | 7 | import { observer, reaction } from '../source/decorator'; 8 | import { component } from '../source/WebCell'; 9 | 10 | configure({ enforceActions: 'never' }); 11 | 12 | class Test { 13 | @observable 14 | accessor count = 0; 15 | } 16 | 17 | describe('Observer decorator', () => { 18 | const model = new Test(), 19 | renderer = new DOMRenderer(); 20 | 21 | it('should re-render Function Component', () => { 22 | const InlineTag = observer(() => {model.count}); 23 | 24 | renderer.render(); 25 | 26 | expect(document.body.textContent.trim()).toBe('0'); 27 | 28 | model.count++; 29 | 30 | expect(document.body.textContent.trim()).toBe('1'); 31 | }); 32 | 33 | it('should re-render Class Component', () => { 34 | @component({ tagName: 'test-tag' }) 35 | @observer 36 | class TestTag extends HTMLElement { 37 | render() { 38 | return {model.count}; 39 | } 40 | } 41 | renderer.render(); 42 | 43 | expect(document.querySelector('test-tag i').textContent.trim()).toBe( 44 | '1' 45 | ); 46 | model.count++; 47 | 48 | expect(document.querySelector('test-tag i').textContent.trim()).toBe( 49 | '2' 50 | ); 51 | }); 52 | 53 | it('should register a Reaction with MobX', async () => { 54 | const handler = jest.fn(); 55 | 56 | @component({ tagName: 'reaction-cell' }) 57 | @observer 58 | class ReactionCell extends HTMLElement { 59 | @observable 60 | accessor test = ''; 61 | 62 | @reaction(({ test }) => test) 63 | handleReaction(value: string) { 64 | handler(value); 65 | } 66 | } 67 | renderer.render(); 68 | 69 | await sleep(); 70 | 71 | const tag = document.querySelector('reaction-cell'); 72 | tag.test = 'a'; 73 | 74 | await sleep(); 75 | 76 | expect(handler).toHaveBeenCalledTimes(1); 77 | expect(handler).toHaveBeenCalledWith('a'); 78 | 79 | document.body.innerHTML = ''; 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/WebCell.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'element-internals-polyfill'; 2 | 3 | import { DOMRenderer } from 'dom-renderer'; 4 | import { configure, observable } from 'mobx'; 5 | import { sleep, stringifyCSS } from 'web-utility'; 6 | 7 | import { attribute, observer } from '../source/decorator'; 8 | import { component, on, WebCell, WebCellProps } from '../source/WebCell'; 9 | 10 | configure({ enforceActions: 'never' }); 11 | 12 | describe('Base Class & Decorator', () => { 13 | const renderer = new DOMRenderer(); 14 | 15 | it('should define a Custom Element', () => { 16 | @component({ 17 | tagName: 'x-first', 18 | mode: 'open' 19 | }) 20 | class XFirst extends HTMLElement {} 21 | 22 | renderer.render(); 23 | 24 | expect(customElements.get('x-first')).toBe(XFirst); 25 | expect(document.body.lastElementChild.tagName).toBe('X-FIRST'); 26 | }); 27 | 28 | it('should inject CSS into Shadow Root', async () => { 29 | @component({ 30 | tagName: 'x-second', 31 | mode: 'open' 32 | }) 33 | class XSecond extends HTMLElement { 34 | private innerStyle = stringifyCSS({ 35 | h2: { color: 'red' } 36 | }); 37 | 38 | render() { 39 | return ( 40 | <> 41 | 42 |

43 | 44 | ); 45 | } 46 | } 47 | renderer.render(); 48 | 49 | await sleep(); 50 | 51 | const { shadowRoot } = document.body.lastElementChild as XSecond; 52 | 53 | expect(shadowRoot.innerHTML).toBe(`

`); 56 | }); 57 | 58 | it('should put .render() returned DOM into .children of a Custom Element', () => { 59 | @component({ tagName: 'x-third' }) 60 | class XThird extends HTMLElement { 61 | render() { 62 | return

; 63 | } 64 | } 65 | renderer.render(); 66 | 67 | const { shadowRoot, innerHTML } = document.body.lastElementChild; 68 | 69 | expect(shadowRoot).toBeNull(); 70 | expect(innerHTML).toBe('

'); 71 | }); 72 | 73 | it('should update Property & Attribute by watch() & attribute() decorators', async () => { 74 | interface XFourthProps extends WebCellProps { 75 | name?: string; 76 | } 77 | 78 | interface XFourth extends WebCell {} 79 | 80 | @component({ tagName: 'x-fourth' }) 81 | @observer 82 | class XFourth extends HTMLElement implements WebCell { 83 | @attribute 84 | @observable 85 | accessor name: string | undefined; 86 | 87 | render() { 88 | return

{this.name}

; 89 | } 90 | } 91 | renderer.render(); 92 | 93 | const tag = document.body.lastElementChild as XFourth; 94 | 95 | expect(tag.innerHTML).toBe('

'); 96 | 97 | tag.name = 'test'; 98 | 99 | expect(tag.name).toBe('test'); 100 | 101 | await sleep(); 102 | 103 | expect(tag.getAttribute('name')).toBe('test'); 104 | expect(tag.innerHTML).toBe('

test

'); 105 | 106 | tag.setAttribute('name', 'example'); 107 | 108 | expect(tag.name).toBe('example'); 109 | }); 110 | 111 | it('should delegate DOM Event by on() decorator', () => { 112 | interface XFirthProps extends WebCellProps { 113 | name?: string; 114 | } 115 | 116 | interface XFirth extends WebCell {} 117 | 118 | @component({ tagName: 'x-firth' }) 119 | @observer 120 | class XFirth extends HTMLElement implements WebCell { 121 | @observable 122 | accessor name: string | undefined; 123 | 124 | @on('click', 'h2') 125 | handleClick( 126 | { type, detail }: CustomEvent, 127 | { tagName }: HTMLHeadingElement 128 | ) { 129 | this.name = [type, tagName, detail] + ''; 130 | } 131 | 132 | render() { 133 | return ( 134 |

135 | {this.name} 136 |

137 | ); 138 | } 139 | } 140 | renderer.render(); 141 | 142 | const tag = document.body.lastElementChild as XFirth; 143 | 144 | tag.querySelector('a').dispatchEvent( 145 | new CustomEvent('click', { bubbles: true, detail: 1 }) 146 | ); 147 | expect(tag.name).toBe('click,H2,1'); 148 | }); 149 | 150 | it('should extend Original HTML tags', () => { 151 | @component({ 152 | tagName: 'x-sixth', 153 | extends: 'blockquote', 154 | mode: 'open' 155 | }) 156 | class XSixth extends HTMLQuoteElement { 157 | render() { 158 | return ( 159 | <> 160 | 💖 161 | 162 | 163 | ); 164 | } 165 | } 166 | renderer.render(
test
); 167 | 168 | const element = document.querySelector('blockquote'); 169 | 170 | expect(element).toBeInstanceOf(XSixth); 171 | expect(element).toBeInstanceOf(HTMLQuoteElement); 172 | 173 | expect(element.textContent).toBe('test'); 174 | expect(element.shadowRoot.innerHTML).toBe('💖'); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /test/WebField.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'element-internals-polyfill'; 2 | 3 | import { DOMRenderer } from 'dom-renderer'; 4 | import { configure } from 'mobx'; 5 | import { sleep } from 'web-utility'; 6 | 7 | import { observer } from '../source/decorator'; 8 | import { component, WebCellProps } from '../source/WebCell'; 9 | import { formField, WebField } from '../source/WebField'; 10 | 11 | configure({ enforceActions: 'never' }); 12 | 13 | describe('Field Class & Decorator', () => { 14 | const renderer = new DOMRenderer(); 15 | 16 | interface TestInputProps extends WebCellProps { 17 | a?: number; 18 | } 19 | 20 | interface TestInput extends WebField {} 21 | 22 | @component({ tagName: 'test-input' }) 23 | @formField 24 | @observer 25 | class TestInput extends HTMLElement implements WebField {} 26 | 27 | it('should define a Custom Field Element', () => { 28 | renderer.render(); 29 | 30 | expect(customElements.get('test-input')).toBe(TestInput); 31 | 32 | expect(document.querySelector('test-input').tagName.toLowerCase()).toBe( 33 | 'test-input' 34 | ); 35 | }); 36 | 37 | it('should have simple Form properties', async () => { 38 | const input = document.querySelector('test-input'); 39 | 40 | input.name = 'test'; 41 | await sleep(); 42 | expect(input.getAttribute('name')).toBe('test'); 43 | 44 | input.required = true; 45 | await sleep(); 46 | expect(input.hasAttribute('required')).toBeTruthy(); 47 | }); 48 | 49 | it('should have advanced Form properties', () => { 50 | const input = new TestInput(); 51 | 52 | input.defaultValue = 'example'; 53 | expect(input.defaultValue === input.getAttribute('value')).toBeTruthy(); 54 | 55 | const form = document.createElement('form'); 56 | form.append(input); 57 | expect(input.form === form).toBeTruthy(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES6", 5 | "module": "CommonJS", 6 | "types": ["jest"] 7 | }, 8 | "include": ["./*", "../source/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist/", 4 | "target": "ES2017", 5 | "checkJs": true, 6 | "declaration": true, 7 | "module": "ES2022", 8 | "moduleResolution": "Node", 9 | "esModuleInterop": true, 10 | "useDefineForClassFields": true, 11 | "jsx": "react-jsx", 12 | "jsxImportSource": "dom-renderer", 13 | "skipLibCheck": true, 14 | "lib": ["ES2022", "DOM"] 15 | }, 16 | "include": ["source/**/*", "*.ts"], 17 | "typedocOptions": { 18 | "name": "WebCell", 19 | "excludeExternals": true, 20 | "excludePrivate": true, 21 | "readme": "./ReadMe.md", 22 | "plugin": ["typedoc-plugin-mdn-links"], 23 | "customCss": "./guide/table.css" 24 | } 25 | } 26 | --------------------------------------------------------------------------------