├── .bmp.yml ├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── deno.json ├── dnt.ts ├── mod.ts └── src ├── add_hidden_item.ts ├── decorators ├── component.ts ├── component_test.ts ├── emits.ts ├── emits_test.ts ├── index.ts ├── inner_html.ts ├── inner_html_test.ts ├── is.ts ├── is_test.ts ├── on.ts ├── on_click.ts ├── on_click_at.ts ├── on_test.ts ├── on_use_handler.ts ├── pub.ts ├── pub_test.ts ├── sub.ts ├── sub_test.ts ├── wired.ts └── wired_test.ts ├── def.ts ├── def_test.ts ├── dom_polyfill_deno.ts ├── dom_polyfill_node.ts ├── get.ts ├── get_test.ts ├── init_component.ts ├── init_component_test.ts ├── install.ts ├── install_test.ts ├── make.ts ├── make_test.ts ├── mod.ts ├── mount.ts ├── mount_test.ts ├── plugins ├── debug_plugin.ts ├── debug_plugin_test.ts ├── outside_events_plugin.ts └── outside_events_plugin_test.ts ├── prep.ts ├── prep_test.ts ├── registry.ts ├── td_deno.ts ├── td_node.ts ├── test_fixture.ts ├── test_helper.ts ├── unmount.ts ├── unmount_test.ts └── util ├── check.ts ├── const.ts ├── debug_message.ts ├── document.ts └── event_trigger.ts /.bmp.yml: -------------------------------------------------------------------------------- 1 | version: 1.8.2 2 | commit: ':bookmark: chore(version): bump to v%.%.%' 3 | files: 4 | dnt.ts: 'version: "%.%.%"' 5 | README.md: 6 | - 'https://shields.io/badge/deno.land/x-v%.%.%-green' 7 | - 'https://deno.land/x/capsid@v%.%.%/mod.ts' 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_size = 2 7 | [Makefile] 8 | indent_style = tab 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: denoland/setup-deno@v1 9 | with: 10 | deno-version: '1.x' 11 | - name: Run fmt check 12 | run: make fmt-check 13 | - name: Run unit tests 14 | run: make test 15 | - name: Run dnt 16 | run: make dnt 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | /cov 4 | /dist.js 5 | /dist.min.js 6 | /node 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true, 5 | "deno.config": "./deno.json", 6 | "deno.suggest.imports.hosts": { 7 | "https://deno.land": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | Copyright © 2015 Yoshiya Hinosawa ( @kt3k ) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | deno test --config deno.json -A --unstable --no-check --ignore=node 4 | 5 | .PHONY: cov 6 | cov: 7 | deno test --coverage=cov --config deno.json -A --unstable --no-check 8 | 9 | .PHONY: fmt 10 | fmt: 11 | deno fmt --config deno.json 12 | 13 | .PHONY: fmt-check 14 | fmt-check: 15 | deno fmt --check --config deno.json 16 | 17 | .PHONY: lint 18 | lint: 19 | deno lint --config deno.json 20 | 21 | .PHONY: dist 22 | dist: 23 | deno bundle --config deno.json src/mod.ts > dist.js 24 | 25 | .PHONY: min 26 | min: 27 | $(MAKE) dist 28 | terser --compress --mangle -o dist.min.js -- dist.js 29 | 30 | .PHONY: size 31 | size: 32 | $(MAKE) min 33 | deno run --allow-read https://deno.land/x/gzip_size@v0.2.3/cli.ts --include-original dist.min.js 34 | 35 | .PHONY: dnt 36 | dnt: 37 | deno run -A dnt.ts 38 | 39 | .PHONY: npm-publish 40 | npm-publish: 41 | rm -rf node 42 | $(MAKE) dnt 43 | cd node && npm publish 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![ci](https://github.com/capsidjs/capsid/actions/workflows/ci.yml/badge.svg)](https://github.com/capsidjs/capsid/actions/workflows/ci.yml) 4 | [![deno.land/x](https://shields.io/badge/deno.land/x-v1.8.2-green?logo=deno&style=flat)](https://deno.land/x/capsid) 5 | [![npm](https://img.shields.io/npm/v/capsid.svg)](https://npm.im/capsid) 6 | 7 | - **Declarative DOM programming library** based on **TypeScript decorators** 8 | - :leaves: **Small.** **1.79 kb.** **No dependencies.** 9 | - :sunny: **No special syntax.** Capsid uses **standard** HTML and TypeScript, 10 | and it **doesn't** use any **non-standard** syntax like JSX, Vue template, 11 | etc. 12 | - :bulb: **Simple.** No virtual DOMs. Capsid encourages the traditional event 13 | driven programming in a new style. 14 | 15 | # :butterfly: [Mirroring Example][Mirroring Example] 16 | 17 | This example illustrates the basic ideas of `capsid`. 18 | 19 | ```ts 20 | import { component, on, wired } from "capsid"; 21 | 22 | // Declares `mirroring` component. 23 | // HTML elements which have `mirroring` class will be mounted by this component. 24 | @component("mirroring") 25 | class Mirroring { 26 | // Wires `dest` property to dom which is selected by `.dest` selector. 27 | @wired(".dest") 28 | dest!: HTMLParagraphElement; 29 | 30 | // Wires `src` property to dom which is selected by `.src` selector. 31 | @wired(".src") 32 | src!: HTMLInputElement; 33 | 34 | // Declares `input` event listener 35 | @on("input") 36 | onReceiveData() { 37 | this.dest.textContent = this.src.value; 38 | } 39 | } 40 | ``` 41 | 42 | ```html 43 |
44 | 45 |

46 |
47 | ``` 48 | 49 | `@component("mirroring")` registers the class as the component `mirroring`. 50 | 51 | `@wired` binds a dom element to the field which is queried by the given 52 | selector. `@on("input")` declares the following method is the `input` event 53 | handler. In the event handler `src` value is copied to `dest` content, which 54 | results the mirroring of the input values to the textContent of `.dest` 55 | paragraph. 56 | 57 | [See the demo][Mirroring Example] 58 | 59 | # :cd: Install 60 | 61 | ## Via npm 62 | 63 | npm install --save capsid 64 | 65 | then: 66 | 67 | ```js 68 | import { component } from "capsid"; 69 | ``` 70 | 71 | Note: You need TypeScript for using capsid because it depends on TypeScript 72 | decorators. You can easily start using TypeScript by using bundlers like 73 | [parcel][parcel] 74 | 75 | ## Via deno.land/x 76 | 77 | If you prefer [Deno](https://deno.land/), you can import capsid via 78 | `deno.land/x` registry. 79 | 80 | ```js 81 | import { component } from "https://deno.land/x/capsid@v1.8.2/mod.ts"; 82 | ``` 83 | 84 | # Decorators 85 | 86 | ```js 87 | import { component, emits, innerHTML, is, on, pub, sub, wired } from "capsid"; 88 | ``` 89 | 90 | - `@component(name)` 91 | - _class decorator_ 92 | - registers as a capsid components. 93 | - `@on(event, { at })` 94 | - _method decorator_ 95 | - registers as an event listener on the component. 96 | - `@on.click` is a shorthand for `@on('click')`. 97 | - `@on.click.at(selector)` is a shorthand for 98 | `@on('click', { at: selector })`. 99 | - `@emits(event)` 100 | - _method decorator_ 101 | - makes the decorated method an event emitter. 102 | - `@wired(selector)` 103 | - _field decorator_ 104 | - wires the elements to the decorated field by the given selector. 105 | - optionally `@wired.all(selector)` 106 | - `@is(name)` 107 | - _class decorator_ 108 | - Adds the class name to the given element. 109 | - `@innerHTML(html: string)` 110 | - _class decorator_ 111 | - Sets the given html string as innerHTML of the element at the mount timing. 112 | - `@pub(event: string, selector?: string)` 113 | - _methods decorator_ 114 | - Publishes the event to the elements which have `sub:event` class. 115 | - `@sub(event: string)` 116 | - _class decorator_ 117 | - Adds the `sub:event` class to the given element. 118 | 119 | ## `@component(name: string)` 120 | 121 | capsid.component(className) is class decorator. With this decorator, you can 122 | regiter the js class as class component. 123 | 124 | This is a shorthand of `capsid.def('component', Component)`. 125 | 126 | ```js 127 | import { component } from 'capsid' 128 | 129 | @component('timer') 130 | class Timer { 131 | ...definitions... 132 | } 133 | ``` 134 | 135 | The above registers `Timer` class as `timer` component. 136 | 137 | ## `@on(event: string)` 138 | 139 | `@on` is a method decorator. With this decorator, you can register the method as 140 | the event handler of the element. 141 | 142 | ```js 143 | import { on, component } from 'capsid' 144 | 145 | @component('foo-btn') 146 | class FooButton { 147 | 148 | @on('click') 149 | onClick (e) { 150 | ...definitions... 151 | } 152 | } 153 | ``` 154 | 155 | The above binds `onClick` method to its element's 'click' event automatically. 156 | 157 | The above is equivalent of: 158 | 159 | ```js 160 | class FooButton { 161 | __mount__ () { 162 | this.el.addEventListener('click', e => { 163 | this.onClick(e) 164 | }) 165 | } 166 | 167 | onClick (e) { 168 | ...definitions... 169 | } 170 | } 171 | 172 | capsid.def('foo-btn', FooButton) 173 | ``` 174 | 175 | ## `@on(event: string, { at }: { at: string })` 176 | 177 | `@on(name, { at: selector })` is a method decorator. It's similar to `@on`, but 178 | it only handles the event from `selector` in the component. 179 | 180 | ```js 181 | import { on, component } from 'capsid' 182 | 183 | @component('btn') 184 | class Btn { 185 | @on('click', { at: '.btn' }) 186 | onBtnClick (e) { 187 | ...definitions... 188 | } 189 | } 190 | ``` 191 | 192 | In the above example, `onBtnClick` method listens to the click event of the 193 | `.btn` element in the `Btn`'s element. 194 | 195 | ## `@on.click` 196 | 197 | `@on.click` is a shorthand for `@on('click')`. 198 | 199 | ```js 200 | class Foo { 201 | @on.click 202 | onClick { 203 | // handling of the click of the Foo component 204 | } 205 | } 206 | ``` 207 | 208 | ## `@on.click.at(selector: string)` 209 | 210 | `@on.click.at(selector)` is a shorthand for `@on('click', { at: selector })` 211 | 212 | ```js 213 | class Foo { 214 | @on.click.at(".edit-button") 215 | onClickAtEditButton() { 216 | // handling of the click of the edit button 217 | } 218 | } 219 | ``` 220 | 221 | **NOTE:** You can add this type of short hand by calling 222 | `on.useHandler(eventName)`. 223 | 224 | ```js 225 | on.useHandler("change"); 226 | 227 | class Foo { 228 | @on.change.at(".title-input") // <= This is enabled by the above useHandler call. 229 | onChangeAtTitleInput() { 230 | // handles the change event of title input field. 231 | } 232 | } 233 | ``` 234 | 235 | ## `@emits(event: string)` 236 | 237 | `@emits(eventName)` triggers the event at the end of the method. 238 | 239 | ```js 240 | import { emits, component } from 'capsid' 241 | 242 | @component('manager') 243 | class Manager { 244 | @emits('manager.ended') 245 | start() { 246 | ...definitions... 247 | } 248 | } 249 | ``` 250 | 251 | In the above example, `start` method triggers the `manager.ended` event when it 252 | finished. The returns value of the method is passed as `detail` of the event 253 | object. So you can pass the data from children to parents. 254 | 255 | If the method returns a promise, then the event is triggered _after_ the promise 256 | is resolved. 257 | 258 | ```js 259 | const { emits, component } = require('capsid') 260 | 261 | @component('manager') 262 | class Manager { 263 | @emits('manager.ended') 264 | start () { 265 | ...definitions... 266 | 267 | return promise 268 | } 269 | } 270 | ``` 271 | 272 | In the above example, `manager.ended` event is triggered after `promise` is 273 | resolved. The resolved value of the promise is passed as `detail` of the event 274 | object. 275 | 276 | ## `@wired(selector: string) field` 277 | 278 | - @param {string} selector The selector to look up the element in the component 279 | 280 | This wires the decorated field to the element selected by the given selector. 281 | The wired element is a unusal dom element (HTMLElement), not a capsid component 282 | instance. 283 | 284 | If the selector matches to the multiple elements, then the first one is used. 285 | 286 | ## `@wired.all(selector: string) field` 287 | 288 | - @param {string} selector The selector to look up the elements in the component 289 | 290 | This wires the decorated field to the all elements selected by the given 291 | selector. This is similar to `@wired` decorator, but it wires all the elements, 292 | not the first one. 293 | 294 | ## `@is(...classNames: string[])` 295 | 296 | Adds the given class names to the element when it's mounted. 297 | 298 | ```ts 299 | @component("foo") 300 | @is("bar-observer") 301 | class Foo { 302 | } 303 | 304 | make("foo", document.body); 305 | 306 | document.body.classList.contains("bar-observer"); 307 | // => true 308 | ``` 309 | 310 | This decorator is useful when a component has several different roles. You can 311 | adds the role of the component by specifying `@is('class-name')`. 312 | 313 | ## `@innerHTML(html: string)` 314 | 315 | Sets the given html string as the innerHTML of the element at mount timing. 316 | 317 | ```ts 318 | @component("foo") 319 | @innerHTML(` 320 |

hello

321 | `) 322 | class Foo { 323 | } 324 | 325 | make("foo", document.body); 326 | 327 | document.body.innerHTML; 328 | // =>

hello

329 | ``` 330 | 331 | ## `@pub(event: string)` 332 | 333 | The method dispatches the `event` to the elements which have `sub:{event}` 334 | class. For example, if the method has `@pub('foo')`, then it dispatches `foo` 335 | event to the elements which have `sub:foo` class. The dispatched events don't 336 | buble up the dom tree. 337 | 338 | ```ts 339 | @component("my-comp") 340 | class MyComp { 341 | @pub("foo") 342 | method() { 343 | // something ... 344 | } 345 | } 346 | ``` 347 | 348 | The returned value or resolved value of the decorator becomes the `detail` prop 349 | of the dispatched custom event. 350 | 351 | ## `@pub(event: string, selector: string)` 352 | 353 | The method dispatches `event` to the given `selector`. 354 | 355 | ```ts 356 | @component("my-comp") 357 | class MyComp { 358 | @pub("foo", "#foo-receiver") 359 | method() { 360 | // something ... 361 | } 362 | } 363 | ``` 364 | 365 | ## `@sub(event: string)` 366 | 367 | This class decorator adds the `sub:event` class to the given component. For 368 | example if you use `@sub('foo')`, the component have `sub:foo` class, which 369 | means this class becomes the subscriber of `foo` event in combination with 370 | `@pub('foo')` decorator. 371 | 372 | ```ts 373 | @component("my-comp") 374 | @sub("foo") 375 | class MyComp { 376 | @on("foo") 377 | handler() { 378 | // ... do something 379 | } 380 | } 381 | ``` 382 | 383 | # APIs 384 | 385 | These are advanced APIs of capsid. You usually don't need these APIs for 386 | building an app, but these could be useful if you write capsid plugins or 387 | reusable capsid modules. These APIs are used for building decorators of capsid. 388 | 389 | ```js 390 | import { def, get, install, make, mount, prep, unmount } from "capsid"; 391 | ``` 392 | 393 | - `def(name, constructor)` 394 | - Registers class-component. 395 | - `prep([name], [element])` 396 | - Initialize class-component on the given range. 397 | - `make(name, element)` 398 | - Initializes the element with the component of the given name and return the 399 | coelement instance. 400 | - `mount(Constructor, element)` 401 | - Initializes the element with the component of the given class and return the 402 | coelement. 403 | - `unmount(name, element)` 404 | - unmount the component from the element by its name. 405 | - `get(name, element)` 406 | - Gets the coelement instance from the given element. 407 | - `install(capsidModule, options)` 408 | - installs the capsid module with the given options. 409 | 410 | ## `def(name, constructor)` 411 | 412 | - @param {string} name The class name of the component 413 | - @param {Function} constructor The constructor of the coelement of the 414 | component 415 | 416 | This registers `constructor` as the constructor of the coelement of the class 417 | component of the given name `name`. The constructor is called with a jQuery 418 | object of the dom as the first parameter and the instance of the coelement is 419 | attached to the dom. The instance of coelement can be obtained by calling 420 | `elem.cc.get(name)`. 421 | 422 | Example: 423 | 424 | ```js 425 | class TodoItem { 426 | // ...behaviours... 427 | } 428 | 429 | capsid.def("todo-item", TodoItem); 430 | ``` 431 | 432 | ```html 433 |
  • 434 | ``` 435 | 436 | ## `prep([name], [element])` 437 | 438 | - @param {string} [name] The capsid component name to intialize 439 | - @param {HTMLElement} [element] The range to initialize 440 | 441 | This initializes the capsid components of the given name under the given 442 | element. If the element is omitted, it initializes in the entire page. If the 443 | name is omitted, then it initializes all the registered class components in the 444 | given range. 445 | 446 | ## `make(name, element)` 447 | 448 | - @param {string} name The capsid component name to initialize 449 | - @param {HTMLElement} element The element to initialize 450 | - @return {} created coelement 451 | 452 | Initializes the element as the capsid component and returns the coelement 453 | instance. 454 | 455 | ```js 456 | const timer = make("timer", dom); 457 | ``` 458 | 459 | ## `mount(Constructor, element)` 460 | 461 | - @param {Function} Constructor The constructor which defines the capsid 462 | component 463 | - @param {HTMLElemen} element The element to mount the component 464 | - @return {} The created coelement 465 | 466 | Initializes the element with the component of the given class and return the 467 | coelement. 468 | 469 | ```js 470 | class Component { 471 | __mount__ () { 472 | this.el.foo = 1 473 | } 474 | } 475 | 476 | const div = document.createElement('div') 477 | 478 | capsid.mount(Component, div) 479 | 480 | div.foo === 1 # => true 481 | ``` 482 | 483 | Usually you don't need to use this API. If you're writing library using capsid, 484 | you might sometimes need to create an unnamed component and need this API then. 485 | 486 | ## `unmount(name, element)` 487 | 488 | - @param {string} name The component name 489 | - @param {HTMLElement} element The element 490 | 491 | Unmounts the component of the given name from the element. 492 | 493 | Example: 494 | 495 | ```js 496 | @component("foo") 497 | class Foo { 498 | @on("input") 499 | remove() { 500 | unmount("foo", this.el); 501 | } 502 | } 503 | ``` 504 | 505 | The above example unmounts itself when it receives `input` event. 506 | 507 | ## `get(name, element)` 508 | 509 | - @param {string} name The capsid component name to get 510 | - @param {HTMLElement} element The element 511 | - @return The coelement instance 512 | 513 | Gets the component instance from the element. 514 | 515 | ```js 516 | const timer = capsid.get("timer", el); 517 | ``` 518 | 519 | The above gets timer coelement from `el`, which is instance of `Timer` class. 520 | 521 | ### `install(capsidModule[, options])` 522 | 523 | - @param {CapsidModule} capsidModule The module to install 524 | - @param {Object} options The options to pass to the module 525 | 526 | This installs the capsid module. 527 | 528 | ```js 529 | capsid.install(require("capsid-popper"), { name: "my-app-popper" }); 530 | ``` 531 | 532 | See [capsid-module][capsid-module] repository for details. 533 | 534 | # Plugins 535 | 536 | ## Debug plugin 537 | 538 | `debug plugin` outputs information useful for debugging capsid app. 539 | 540 | ### Install 541 | 542 | Via npm: 543 | 544 | ```js 545 | import { install } from "capsid"; 546 | import debug from "capsid/debug"; 547 | install(debug); 548 | ``` 549 | 550 | Via CDN: 551 | 552 | ```html 553 | 554 | 555 | 556 | ``` 557 | 558 | And you'll get additional debug information in console. 559 | 560 | 561 | 562 | ## Outside Events Plugin 563 | 564 | ### Install 565 | 566 | Via npm: 567 | 568 | ```js 569 | import { install } from "capsid"; 570 | import outside from "capsid/outside"; 571 | install(outside); 572 | ``` 573 | 574 | Via cdn: 575 | 576 | ```html 577 | 578 | 579 | 582 | ``` 583 | 584 | With `outside-events-plugin`, you can bind methods to events _outside_ of your 585 | coponent's element. (This event need to bubble up to `document`) 586 | 587 | ```js 588 | @component("modal") 589 | class Modal { 590 | @on.outside("click") 591 | close() { 592 | this.el.classList.remove("is-shown"); 593 | } 594 | 595 | open() { 596 | this.el.classList.add("is-shown"); 597 | } 598 | } 599 | ``` 600 | 601 | The above `modal` component gets `is-shown` class removed from the element when 602 | the outside of modal is clicked. 603 | 604 | #### prior art of capsid outside plugin 605 | 606 | - [jQuery outside events](https://github.com/cowboy/jquery-outside-events) 607 | - [react-onclickoutside](https://github.com/Pomax/react-onclickoutside) 608 | 609 | # Initialization 610 | 611 | There are 2 ways to initialize components: 612 | 613 | 1. [When document is ready][DOMContentLoaded] (automatic). 614 | 2. When `capsid.prep()` is called (manual). 615 | 616 | All components are initialized automatically when document is ready. You don't 617 | need to care about those elements which exist before document is ready. See 618 | [Hello Example][Hello Example] or [Clock Example][Clock Example] for example. 619 | 620 | If you add elements after document is ready (for example, after ajax requests), 621 | call `capsid.prep()` and that initializes all the components. 622 | 623 | ```js 624 | const addPartOfPage = async () => { 625 | const { html } = await axios.get('path/to/something.html') 626 | 627 | containerElemenent.innerHTML = html 628 | 629 | capsid.prep() // <= this initializes all the elements which are not yet initialized. 630 | }) 631 | ``` 632 | 633 | # Capsid Lifecycle 634 | 635 | Capsid has 2 lifecycle events: `mount` and `unmount`. 636 | 637 | ``` 638 | nothing -> [mount] -> component mounted -> [unmount] -> nothing 639 | ``` 640 | 641 | ## Lifecycle events 642 | 643 | - `mount` 644 | - HTML elements are mounted by the components. 645 | - An element is coupled with the corresponding coelement and they start 646 | working together. 647 | 648 | - `unmount` 649 | - An element is decouple with the coelement. 650 | - All events are removed and coelement is discarded. 651 | - You need to call `unmount(class, element)` to trigger the unmount event. 652 | 653 | ## Explanation of `mount` 654 | 655 | At `mount` event, these things happen. 656 | 657 | - The component class's `instance` (coelement) is created. 658 | - `instance`.el is set to corresponding dom element. 659 | - `before mount`-hooks are invoked. 660 | - This includes the initialization of event handlers, class names, innerHTML, 661 | and custom plugin's hooks. 662 | - if `instance` has **mount** method, then `instance.__mount__()` is called. 663 | 664 | The above happens in this order. Therefore you can access `this.el` and you can 665 | invoke the events at `this.el` in `__mount__` method. 666 | 667 | ## Lifecycle Methods 668 | 669 | ### `constructor` 670 | 671 | The constructor is called at the start of `mount`ing. You cannot access 672 | `this.el` here. If you need to interact with `this.el`, use `__mount__` method. 673 | 674 | ### `__mount__` 675 | 676 | `__mount__()` is called at the **end** of the mount event. When it is called, 677 | the dom element and event handlers are ready and available through `this.el`. 678 | 679 | ### `__unmount__` 680 | 681 | `__unmount__()` is called when component is unmounted. If your component put 682 | resources on global space, you should discard them here to avoid memory leak. 683 | 684 | # Coelement 685 | 686 | Coelement is the instance of Component class, which is attached to html element. 687 | You can get coelement from the element using `get` API. 688 | 689 | # History 690 | 691 | - 2022-01-03 v1.8.1 Modify npm package contents. #212 692 | - 2022-01-03 v1.8.0 Migrated to Deno. #212 693 | - 2020-04-02 v1.7.0 Better make/get/unmount types. 694 | - 2020-03-30 v1.6.2 Fix submodule export for TypeScript. 695 | - 2020-03-28 v1.6.1 Fix debug plugin. 696 | - 2020-03-28 v1.6.0 Automatic intialization of components inside `@innerHTML`. 697 | - 2020-03-21 v1.5.0 Extend `@pub` decorator and remove `@notifies`. 698 | - 2020-03-21 v1.4.0 Add `@innerHTML` decorator. 699 | - 2020-03-15 v1.3.0 Add `@pub` and `@sub` decorators. 700 | - 2020-03-14 v1.2.0 Add `@is` decorator. 701 | - 2020-03-13 v1.1.0 Add type declaration. 702 | - 2020-03-12 v1.0.0 Support TypeScript decorators. Drop babel decorators 703 | support. 704 | - 2019-06-09 v0.29.2 Throw error when empty selector is given (`@notifies`) 705 | - 2018-12-01 v0.29.0 Switch to TypeScript. 706 | - 2018-11-22 v0.28.0 Switch to new decorator. Remove jquery-plugin. 707 | - 2018-08-07 v0.26.1 Fix bug of unmount and on handler. 708 | - 2018-07-12 v0.26.0 Add debug log contents. 709 | - 2018-06-22 v0.25.0 Add `@on.useHandler`. 710 | - 2018-06-22 v0.24.0 Add `@on.click.at`. 711 | - 2018-05-20 v0.23.5 Fix unmount bug. 712 | - 2018-04-18 v0.23.4 Fix unmount bug. 713 | - 2018-04-10 v0.23.0 Change debug format. 714 | - 2018-04-09 v0.22.0 Rename **init** to **mount**. 715 | - 2018-04-08 v0.21.0 Add `unmount`. 716 | - 2018-04-04 v0.20.3 Change initialized class name. 717 | - 2018-03-08 v0.20.0 Add install function. 718 | - 2017-12-31 v0.19.0 Add wired, wired.all and wired.component decorators. 719 | - 2017-12-05 v0.18.3 Add an error message. 720 | - 2017-10-12 v0.18.0 Add Outside Events plugin. 721 | - 2017-10-01 v0.17.0 Add Debug plugin. 722 | - 2017-09-09 v0.16.0 Rename `@emit` to `@emits` and `@pub` to `@notifies` 723 | - 2017-09-06 v0.15.1 Change component init sequence. 724 | - 2017-09-05 v0.15.0 Add `mount` API. Remove `init` API. 725 | - 2017-08-04 v0.14.0 Make `@on` listeners ready at **init** call. 726 | - 2017-08-03 v0.13.0 Add pub decorator. 727 | - 2017-07-15 v0.12.0 Add wire.$el and wire.elAll to jquery plugin. 728 | - 2017-07-13 v0.11.0 Add wire.el and wire.elAll. 729 | - 2017-07-11 v0.10.0 Add emit.first rename emit.last to emit. 730 | - 2017-07-10 v0.9.0 Add on.click shorthand. 731 | - 2017-03-01 v0.8.0 Modify init sequence. 732 | - 2017-02-26 v0.7.0 Add static capsid object to each coelement class. 733 | - 2017-02-26 v0.6.0 static **init** rule. 734 | - 2017-02-25 v0.5.0 coelem.capsid, initComponent APIs. 735 | - 2017-01-19 v0.3.0 API reorganization. 736 | - 2017-01-19 v0.2.2 Rename to capsid. 737 | - 2017-01-17 v0.1.1 Add plugin system. 738 | 739 | # History of class-component.js (former project) 740 | 741 | - 2017-01-02 v13.0.0 Add **init** instead of init. 742 | - 2017-01-01 v12.1.1 Fix bug of event bubbling. 743 | - 2017-01-01 v12.1.0 Remove @emit.first. Use native dispatchEvent. 744 | - 2016-12-31 v12.0.0 Remove **cc_init** feature. Add init feature. 745 | - 2016-09-30 v10.7.1 Refactor @emit.last decorator 746 | - 2016-09-11 v10.7.0 Add @on(event, {at}) @emit.first and @emit.last 747 | - 2016-08-22 v10.6.2 Refactor the entrypoint. 748 | - 2016-08-22 v10.6.1 Improved the event listener registration process. 749 | - 2016-08-20 v10.6.0 Cleaned up some private APIs. 750 | - 2016-08-20 v10.5.0 Cleaned up codebase and made the bundle smaller. Remove 751 | some private APIs. 752 | - 2016-08-17 v10.4.1 Made built version smaller. 753 | - 2016-08-16 v10.4.0 Switched to babel-preset-es2015-loose. 754 | - 2016-08-16 v10.3.0 Modified bare @wire decorator. 755 | - 2016-08-02 v10.2.0 Added bare @component decorator. 756 | - 2016-07-21 v10.1.0 Added @wire decorator. 757 | - 2016-06-19 v10.0.0 Removed deprecated decorators `@event` and `@trigger`, use 758 | `@on` and `@emit` instead. 759 | - 2016-06-09 v9.2.0 Fixed bug of `@emit().last` decorator. 760 | 761 | # Examples 762 | 763 | - :wave: [Hello Example][Hello Example] 764 | - :stopwatch: [Clock Example][Clock Example] 765 | - :level_slider: [Counter Example][Counter Example] 766 | - :butterfly: [Mirroring Example][Mirroring Example] 767 | 768 | - [todomvc2](https://github.com/capsidjs/todomvc2) 769 | - [TodoMVC](http://todomvc.com/) in capsid. 770 | 771 | # License 772 | 773 | MIT 774 | 775 | [flux]: http://facebook.github.io/flux 776 | [evex]: http://github.com/capsidjs/evex 777 | [Hello Example]: https://codesandbox.io/s/hello-world-capsidjs-example-k5dgl 778 | [Clock Example]: https://codesandbox.io/s/clock-capsidjs-example-i9d7k 779 | [Counter Example]: https://codesandbox.io/s/km023p21nv 780 | [Mirroring Example]: https://codesandbox.io/s/p7m3xv3mvq 781 | [DOMContentLoaded]: https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded 782 | [capsid-module]: https://github.com/capsidjs/capsid-module 783 | [parcel]: https://parceljs.org/ 784 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["deno.ns", "deno.unstable", "dom", "esnext"] 4 | }, 5 | "fmt": { 6 | "files": { 7 | "exclude": ["node", "dist.js", "dist.min.js"] 8 | } 9 | }, 10 | "lint": { 11 | "files": { 12 | "exclude": ["node", "dist.js", "dist.min.js"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /dnt.ts: -------------------------------------------------------------------------------- 1 | import { build } from "https://raw.githubusercontent.com/kt3k/dnt/a20e97cfa0e92a3c688002d2fe838dba2ebb50bb/mod.ts"; 2 | import { join } from "https://deno.land/std@0.119.0/path/mod.ts"; 3 | 4 | const outDir = "node"; 5 | 6 | await build({ 7 | entryPoints: ["./src/mod.ts"], 8 | outDir, 9 | shims: { 10 | deno: "dev", 11 | }, 12 | redirects: { 13 | "./src/dom_polyfill_deno.ts": "./src/dom_polyfill_node.ts", 14 | "./src/td_deno.ts": "./src/td_node.ts", 15 | }, 16 | test: true, 17 | package: { 18 | name: "capsid", 19 | version: "1.8.2", 20 | description: 21 | "Declarative DOM programming library based on TypeScript decorators", 22 | license: "MIT", 23 | repository: { 24 | type: "git", 25 | url: "git+https://github.com/capsidjs/capsid.git", 26 | }, 27 | bugs: { 28 | url: "https://github.com/capsidjs/capsid/issues", 29 | }, 30 | devDependencies: { 31 | jsdom: "^19.0.0", 32 | "@types/jsdom": "^16.2.14", 33 | testdouble: "^3.16.4", 34 | }, 35 | }, 36 | }); 37 | 38 | Deno.copyFileSync("LICENSE", join(outDir, "LICENSE")); 39 | Deno.copyFileSync("README.md", join(outDir, "README.md")); 40 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/mod.ts"; 2 | -------------------------------------------------------------------------------- /src/add_hidden_item.ts: -------------------------------------------------------------------------------- 1 | import { BEFORE_MOUNT_KEY } from "./util/const.ts"; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | const addHiddenItem = (target: any, key: string, hook: unknown) => { 5 | target[key] = (target[key] || []).concat(hook); 6 | }; 7 | 8 | // deno-lint-ignore no-explicit-any 9 | export const addMountHook = (target: any, hook: unknown) => { 10 | addHiddenItem(target, BEFORE_MOUNT_KEY, hook); 11 | }; 12 | 13 | export default addHiddenItem; 14 | -------------------------------------------------------------------------------- /src/decorators/component.ts: -------------------------------------------------------------------------------- 1 | import def from "../def.ts"; 2 | import check from "../util/check.ts"; 3 | 4 | /** 5 | * The decorator for class component registration. 6 | * 7 | * @param name The html class name to mount 8 | */ 9 | // deno-lint-ignore no-explicit-any 10 | const component = (name: string): (desc: any) => void => { 11 | check( 12 | typeof name === "string" && !!name, 13 | "Component name must be a non-empty string", 14 | ); 15 | 16 | // deno-lint-ignore ban-types 17 | return (Cls: Function) => { 18 | def(name, Cls); 19 | }; 20 | }; 21 | 22 | export default component; 23 | -------------------------------------------------------------------------------- /src/decorators/component_test.ts: -------------------------------------------------------------------------------- 1 | import { component, make } from "../mod.ts"; 2 | import { assert, clearComponents, genel } from "../test_helper.ts"; 3 | 4 | Deno.test("@component(name)", async (t) => { 5 | await t.step( 6 | "works as a class decorator and registers the class as a class component of the given name", 7 | async () => { 8 | @component("decorated-component") 9 | class Foo { 10 | el?: HTMLElement; 11 | 12 | __mount__() { 13 | this.el!.setAttribute("this-is", "decorated-component"); 14 | } 15 | } 16 | 17 | const el = genel.div``; 18 | 19 | const foo = make("decorated-component", el); 20 | 21 | assert(foo instanceof Foo); 22 | assert(el.getAttribute("this-is") === "decorated-component"); 23 | 24 | await clearComponents(); 25 | }, 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /src/decorators/emits.ts: -------------------------------------------------------------------------------- 1 | import { triggerToElements } from "../util/event_trigger.ts"; 2 | import check from "../util/check.ts"; 3 | 4 | /** 5 | * `@emits(event)` decorator 6 | * 7 | * This decorator adds the event emission at the end of the method. 8 | * If the method returns the promise, then the event is emitted when it is resolved. 9 | * @param event The event name 10 | */ 11 | const emits = (event: string) => 12 | ( 13 | // deno-lint-ignore no-explicit-any 14 | target: any, 15 | key: string, 16 | // deno-lint-ignore no-explicit-any 17 | descriptor: any, 18 | ) => { 19 | const method = descriptor.value; 20 | const constructor = target.constructor; 21 | 22 | check( 23 | !!event, 24 | `Unable to emits an empty event: constructor=${constructor.name} key=${key}`, 25 | ); 26 | 27 | descriptor.value = function () { 28 | const result = method.apply(this, arguments); 29 | triggerToElements([this.el], event, true, result); 30 | return result; 31 | }; 32 | }; 33 | 34 | export default emits; 35 | -------------------------------------------------------------------------------- /src/decorators/emits_test.ts: -------------------------------------------------------------------------------- 1 | import { def, emits, make } from "../mod.ts"; 2 | import { 3 | assert, 4 | assertEquals, 5 | assertThrows, 6 | clearComponents, 7 | deferred, 8 | genel, 9 | } from "../test_helper.ts"; 10 | 11 | Deno.test("@emits(event)", async (t) => { 12 | await t.step("throws when the empty event is given", async () => { 13 | assertThrows( 14 | () => { 15 | class Component { 16 | // deno-lint-ignore no-explicit-any 17 | @emits(undefined as any) 18 | emitter() { 19 | console.log(); 20 | } 21 | } 22 | console.log(Component); 23 | }, 24 | Error, 25 | "Unable to emits an empty event: constructor=Component key=emitter", 26 | ); 27 | await clearComponents(); 28 | }); 29 | 30 | await t.step( 31 | "makes the method emit the event with the returned value", 32 | async () => { 33 | const p = deferred(); 34 | class Component { 35 | @emits("event-foo") 36 | foo() { 37 | return 321; 38 | } 39 | } 40 | 41 | def("component", Component); 42 | 43 | const el = genel.div``; 44 | 45 | // deno-lint-ignore no-explicit-any 46 | el.addEventListener("event-foo" as any, (e: CustomEvent) => { 47 | assert(e.detail === 321); 48 | 49 | p.resolve(); 50 | }); 51 | 52 | make("component", el).foo(); 53 | await clearComponents(); 54 | await p; 55 | }, 56 | ); 57 | 58 | await t.step( 59 | "makes the method emit the event with the resolved value after the promise resolved", 60 | async () => { 61 | const p = deferred(); 62 | 63 | class Component { 64 | @emits("event-foo") 65 | foo() { 66 | return new Promise((resolve) => { 67 | setTimeout(() => { 68 | resolve(123); 69 | }, 100); 70 | }); 71 | } 72 | } 73 | def("component", Component); 74 | 75 | const el = genel.div``; 76 | 77 | // deno-lint-ignore no-explicit-any 78 | el.addEventListener("event-foo" as any, (e: CustomEvent) => { 79 | assertEquals(e.detail, 123); 80 | 81 | p.resolve(); 82 | }); 83 | 84 | make("component", el).foo(); 85 | await clearComponents(); 86 | await p; 87 | }, 88 | ); 89 | }); 90 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import on from "./on.ts"; 2 | import useHandler from "./on_use_handler.ts"; 3 | 4 | on.useHandler = useHandler; 5 | on.useHandler("click"); 6 | 7 | export { on }; 8 | export { default as emits } from "./emits.ts"; 9 | export { default as wired } from "./wired.ts"; 10 | export { default as component } from "./component.ts"; 11 | export { default as is } from "./is.ts"; 12 | export { default as innerHTML } from "./inner_html.ts"; 13 | export { default as pub } from "./pub.ts"; 14 | export { default as sub } from "./sub.ts"; 15 | -------------------------------------------------------------------------------- /src/decorators/inner_html.ts: -------------------------------------------------------------------------------- 1 | import prep from "../prep.ts"; 2 | import { addMountHook } from "../add_hidden_item.ts"; 3 | 4 | /** 5 | * is decorator adds the class names to the given element when it's mounted. 6 | * @param args The list of class names 7 | */ 8 | export default (innerHTML: string) => 9 | // deno-lint-ignore ban-types 10 | (Cls: Function) => { 11 | addMountHook(Cls, (el: HTMLElement) => { 12 | el.innerHTML = innerHTML; 13 | prep(null, el); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/decorators/inner_html_test.ts: -------------------------------------------------------------------------------- 1 | import innerHTML from "./inner_html.ts"; 2 | import component from "./component.ts"; 3 | import make from "../make.ts"; 4 | import get from "../get.ts"; 5 | import { assert, clearComponents } from "../test_helper.ts"; 6 | 7 | Deno.test("@is", async (t) => { 8 | await t.step("adds the class names to the element", async () => { 9 | const html = ` 10 |

    hello

    11 | `; 12 | @component("foo") 13 | @innerHTML(html) 14 | class Foo {} 15 | 16 | const el = document.createElement("div"); 17 | const coel = make("foo", el); 18 | 19 | assert(coel instanceof Foo); 20 | assert(el.innerHTML, html); 21 | await clearComponents(); 22 | }); 23 | 24 | await t.step("initializes the component inside the innerHTML", async () => { 25 | const html = ` 26 |

    hello

    27 | `; 28 | @component("foo") 29 | @innerHTML(html) 30 | // deno-lint-ignore no-unused-vars 31 | class Foo {} 32 | 33 | @component("bar") 34 | class Bar {} 35 | 36 | const el = document.createElement("div"); 37 | make("foo", el); 38 | 39 | const bar = get("bar", el.querySelector(".bar")!); 40 | 41 | assert(bar instanceof Bar); 42 | await clearComponents(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/decorators/is.ts: -------------------------------------------------------------------------------- 1 | import { addMountHook } from "../add_hidden_item.ts"; 2 | 3 | /** 4 | * is decorator adds the class names to the given element when it's mounted. 5 | * @param args The list of class names 6 | */ 7 | export default (...args: string[]) => 8 | // deno-lint-ignore ban-types 9 | (Cls: Function) => { 10 | addMountHook(Cls, (el: HTMLElement) => { 11 | el.classList.add(...args); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/decorators/is_test.ts: -------------------------------------------------------------------------------- 1 | import is from "./is.ts"; 2 | import component from "./component.ts"; 3 | import make from "../make.ts"; 4 | import { assert, clearComponents } from "../test_helper.ts"; 5 | 6 | Deno.test("@is", async (t) => { 7 | await t.step("adds the class names to the element", async () => { 8 | @component("foo") 9 | @is("bar-observer") 10 | class Foo {} 11 | 12 | const el = document.createElement("div"); 13 | const coel = make("foo", el); 14 | 15 | assert(coel instanceof Foo); 16 | assert(el.classList.contains("bar-observer")); 17 | await clearComponents(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/decorators/on.ts: -------------------------------------------------------------------------------- 1 | import { KEY_EVENT_LISTENERS } from "../util/const.ts"; 2 | import debugMessage from "../util/debug_message.ts"; 3 | import check from "../util/check.ts"; 4 | import addHiddenItem, { addMountHook } from "../add_hidden_item.ts"; 5 | 6 | /** 7 | * The decorator for registering event listener info to the method. 8 | * @param event The event name 9 | * @param at The selector 10 | */ 11 | // deno-lint-ignore no-explicit-any 12 | const on: any = (event: string, { at }: { at?: string } = {}) => 13 | ( 14 | // deno-lint-ignore no-explicit-any 15 | target: any, 16 | key: string, 17 | // deno-lint-ignore no-explicit-any 18 | _: any, 19 | ) => { 20 | const constructor = target.constructor; 21 | check( 22 | !!event, 23 | `Empty event handler is given: constructor=${constructor.name} key=${key}`, 24 | ); 25 | /** 26 | * @param el The element 27 | * @param coel The coelement 28 | * @param name The component name 29 | */ 30 | // deno-lint-ignore no-explicit-any 31 | addMountHook(constructor, (el: HTMLElement, coel: any) => { 32 | const listener = (e: Event): void => { 33 | if ( 34 | !at || 35 | [].some.call(el.querySelectorAll(at), (node: Node) => { 36 | return node === e.target || node.contains(e.target as Node); 37 | }) 38 | ) { 39 | // TODO(kt3k): selectively inject __DEV__ variable 40 | const __DEV__ = true; 41 | if (__DEV__) { 42 | debugMessage({ 43 | type: "event", 44 | module: "💊", 45 | color: "#e0407b", 46 | e, 47 | el, 48 | coel, 49 | }); 50 | } 51 | 52 | coel[key](e); 53 | } 54 | }; 55 | 56 | /** 57 | * Removes the event listener. 58 | */ 59 | listener.remove = () => { 60 | el.removeEventListener(event, listener); 61 | }; 62 | 63 | /** 64 | * Store event listeners to remove it later. 65 | */ 66 | addHiddenItem(coel, KEY_EVENT_LISTENERS, listener); 67 | el.addEventListener(event, listener); 68 | }); 69 | }; 70 | 71 | export default on; 72 | -------------------------------------------------------------------------------- /src/decorators/on_click.ts: -------------------------------------------------------------------------------- 1 | import on from "./on.ts"; 2 | 3 | export default on("click"); 4 | -------------------------------------------------------------------------------- /src/decorators/on_click_at.ts: -------------------------------------------------------------------------------- 1 | import on from "./on.ts"; 2 | 3 | /** 4 | * @param at The selector 5 | */ 6 | export default (at: string) => on("click", { at }); 7 | -------------------------------------------------------------------------------- /src/decorators/on_test.ts: -------------------------------------------------------------------------------- 1 | import { def, make, on } from "../mod.ts"; 2 | import { 3 | assertEquals, 4 | assertThrows, 5 | clearComponents, 6 | deferred, 7 | genel, 8 | } from "../test_helper.ts"; 9 | 10 | Deno.test("@on(event)", async (t) => { 11 | await t.step("throws when the event is empty", async () => { 12 | assertThrows( 13 | () => { 14 | class Component { 15 | @on(undefined) 16 | handler() { 17 | console.log(); 18 | } 19 | } 20 | 21 | def("component", Component); 22 | }, 23 | Error, 24 | "Empty event handler is given: constructor=Component key=handler", 25 | ); 26 | await clearComponents(); 27 | }); 28 | 29 | await t.step( 30 | "registers the method as the event listener of the given event name", 31 | async () => { 32 | const p = deferred(); 33 | class Component { 34 | @on("click") 35 | handler() { 36 | p.resolve(); 37 | } 38 | } 39 | 40 | def("component", Component); 41 | 42 | const el = genel.div``; 43 | 44 | make("component", el); 45 | 46 | el.dispatchEvent(new Event("click")); 47 | await p; 48 | await clearComponents(); 49 | }, 50 | ); 51 | 52 | await t.step( 53 | "registers the method as the event listener for children classes", 54 | async () => { 55 | const p = deferred(); 56 | class Foo { 57 | @on("click") 58 | handler() { 59 | p.resolve(); 60 | } 61 | } 62 | class Bar extends Foo {} 63 | class Baz extends Bar {} 64 | 65 | def("baz", Baz); 66 | 67 | const el = genel.div``; 68 | make("baz", el); 69 | el.dispatchEvent(new Event("click")); 70 | await p; 71 | await clearComponents(); 72 | }, 73 | ); 74 | }); 75 | 76 | Deno.test("@on(event, { at: selector })", async (t) => { 77 | await t.step( 78 | "registers the method as the event listener of the given event name and selector", 79 | async () => { 80 | const p = deferred(); 81 | class Foo { 82 | @on("foo-event", { at: ".inner" }) 83 | foo() { 84 | p.resolve(); 85 | } 86 | @on("bar-event", { at: ".inner" }) 87 | bar() { 88 | p.reject(new Error("bar should not be called")); 89 | } 90 | } 91 | def("foo", Foo); 92 | 93 | const el = genel.div` 94 |
    95 | `; 96 | 97 | make("foo", el); 98 | 99 | if (document.body) { 100 | document.body.appendChild(el); 101 | } 102 | 103 | el.dispatchEvent(new CustomEvent("bar-event", { bubbles: true })); 104 | // FIXME(kt3k): deno_dom doesn't handle bubbling correctly 105 | // We need the following event handler as a workaround. 106 | el.querySelector(".inner")!.addEventListener("foo-event", () => {}); 107 | el.querySelector(".inner")!.dispatchEvent( 108 | new CustomEvent("foo-event", { bubbles: true }), 109 | ); 110 | 111 | if (document.body) { 112 | document.body.removeChild(el); 113 | } 114 | await p; 115 | await clearComponents(); 116 | }, 117 | ); 118 | }); 119 | 120 | Deno.test("@on.click", async (t) => { 121 | await t.step("binds method to click event", async () => { 122 | const p = deferred(); 123 | class Component { 124 | @on.click 125 | handler() { 126 | p.resolve(); 127 | } 128 | } 129 | 130 | def("foo", Component); 131 | 132 | const el = genel.div``; 133 | make("foo", el); 134 | el.dispatchEvent(new Event("click")); 135 | await p; 136 | await clearComponents(); 137 | }); 138 | }); 139 | 140 | Deno.test("@on.click.at", async (t) => { 141 | await t.step("binds method to click event at the given element", async () => { 142 | let res = 0; 143 | 144 | class Component { 145 | @on.click.at(".foo") 146 | foo() { 147 | res += 1; 148 | } 149 | @on.click.at(".bar") 150 | bar() { 151 | res += 2; 152 | } 153 | } 154 | 155 | def("component", Component); 156 | 157 | const el = genel.div` 158 |

    159 |

    160 | `; 161 | 162 | make("component", el); 163 | const foo = el.querySelector(".foo")!; 164 | 165 | // FIXME(kt3k): deno_dom doesn't handle bubbling correctly 166 | // We need the following event handler as a workaround. 167 | foo.addEventListener("click", () => {}); 168 | foo.dispatchEvent(new Event("click", { bubbles: true })); 169 | 170 | assertEquals(res, 1); 171 | await clearComponents(); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/decorators/on_use_handler.ts: -------------------------------------------------------------------------------- 1 | import on from "./on.ts"; 2 | 3 | /** 4 | * Registers the on[eventName] and on[eventName].at decorators. 5 | * @param {string} handlerName 6 | */ 7 | export default (handlerName: string) => { 8 | on[handlerName] = on(handlerName); 9 | on[handlerName].at = (selector: string) => on(handlerName, { at: selector }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/decorators/pub.ts: -------------------------------------------------------------------------------- 1 | import { triggerToElements } from "../util/event_trigger.ts"; 2 | import check from "../util/check.ts"; 3 | 4 | /** 5 | * Publishes the given event to the elements which has `sub:${event}` class. 6 | * For example `@pub('foo')` publishes the `foo` event to the elements 7 | * which have `sub:foo` class. 8 | * @param event The event name 9 | * @param targetSelector? The target selector. Default .sub\:{event} 10 | */ 11 | export default (event: string, targetSelector?: string) => 12 | ( 13 | // deno-lint-ignore no-explicit-any 14 | target: any, 15 | key: string, 16 | // deno-lint-ignore no-explicit-any 17 | descriptor: any, 18 | ) => { 19 | const method = descriptor.value; 20 | const constructor = target.constructor; 21 | 22 | check( 23 | !!event, 24 | `Unable to publish empty event: constructor=${constructor.name} key=${key}`, 25 | ); 26 | 27 | const selector = targetSelector || `.sub\\:${event}`; 28 | 29 | descriptor.value = function () { 30 | const result = method.apply(this, arguments); 31 | triggerToElements( 32 | // deno-lint-ignore no-explicit-any 33 | [].concat.apply([], document.querySelectorAll(selector) as any), 34 | event, 35 | false, 36 | result, 37 | ); 38 | return result; 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/decorators/pub_test.ts: -------------------------------------------------------------------------------- 1 | import component from "./component.ts"; 2 | import pub from "./pub.ts"; 3 | import on from "./on.ts"; 4 | import { def, prep } from "../mod.ts"; 5 | import { 6 | assertEquals, 7 | assertThrows, 8 | clearComponents, 9 | deferred, 10 | genel, 11 | } from "../test_helper.ts"; 12 | 13 | Deno.test("@pub(event)", async (t) => { 14 | await t.step("throws error when empty event is given", async () => { 15 | assertThrows( 16 | () => { 17 | class Component { 18 | // deno-lint-ignore no-explicit-any 19 | @pub(undefined as any) 20 | method() { 21 | console.log(); 22 | } 23 | } 24 | 25 | def("component", Component); 26 | }, 27 | Error, 28 | "Unable to publish empty event: constructor=Component key=method", 29 | ); 30 | await clearComponents(); 31 | }); 32 | 33 | await t.step( 34 | "publishes the event to the elements of the sub:event class", 35 | async () => { 36 | const CUSTOM_EVENT = "foo-bar"; 37 | 38 | class Component { 39 | @pub(CUSTOM_EVENT) 40 | @on("foo") 41 | publish() { 42 | console.log(); 43 | } 44 | } 45 | 46 | def("component", Component); 47 | 48 | const el = genel.div` 49 |
    50 |
    51 |
    52 |
    53 |
    54 |
    55 |
    56 |
    57 |
    58 |
    59 |
    60 |
    61 |
    62 |
    63 |
    64 |
    65 | `; 66 | 67 | const child0 = el.querySelector(".child0")!; 68 | const child1 = el.querySelector(".child1")!; 69 | const child2 = el.querySelector(".child2")!; 70 | const comp = el.querySelector(".component")!; 71 | 72 | document.body.appendChild(el); 73 | 74 | prep(); 75 | 76 | const promise0 = new Promise((resolve) => 77 | child0.addEventListener(CUSTOM_EVENT, resolve) 78 | ); 79 | const promise1 = new Promise((resolve) => 80 | child1.addEventListener(CUSTOM_EVENT, resolve) 81 | ); 82 | const promise2 = new Promise((resolve) => 83 | child2.addEventListener(CUSTOM_EVENT, resolve) 84 | ); 85 | 86 | comp.dispatchEvent(new CustomEvent("foo")); 87 | 88 | await Promise.all([promise0, promise1, promise2]); 89 | 90 | document.body.removeChild(el); 91 | await clearComponents(); 92 | }, 93 | ); 94 | 95 | await t.step("publishes events with the return value as detail", async () => { 96 | const p = deferred(); 97 | const CUSTOM_EVENT = "foo-bar"; 98 | 99 | class Component { 100 | @pub(CUSTOM_EVENT) 101 | @on("foo") 102 | publish() { 103 | return { foo: 123, bar: "baz" }; 104 | } 105 | } 106 | 107 | def("component", Component); 108 | 109 | const el = genel.div` 110 |
    111 |
    112 | `; 113 | document.body.appendChild(el); 114 | const target = el.querySelector(".target"); 115 | const comp = el.querySelector(".component"); 116 | 117 | prep(); 118 | 119 | // deno-lint-ignore no-explicit-any 120 | target!.addEventListener(CUSTOM_EVENT as any, (e: CustomEvent) => { 121 | assertEquals(e.detail, { foo: 123, bar: "baz" }); 122 | document.body.removeChild(el); 123 | p.resolve(); 124 | }); 125 | 126 | comp!.dispatchEvent(new CustomEvent("foo")); 127 | await p; 128 | await clearComponents(); 129 | }); 130 | 131 | await t.step( 132 | "publishes events with the resolved value as detail if it is async function", 133 | async () => { 134 | const p = deferred(); 135 | const CUSTOM_EVENT = "foo-bar"; 136 | 137 | class Component { 138 | @pub(CUSTOM_EVENT) 139 | @on("foo") 140 | publish() { 141 | return Promise.resolve({ foo: 123, bar: "baz" }); 142 | } 143 | } 144 | 145 | def("component", Component); 146 | 147 | const el = genel.div` 148 |
    149 |
    150 | `; 151 | document.body.appendChild(el); 152 | const target = el.querySelector(".target")!; 153 | const comp = el.querySelector(".component")!; 154 | 155 | prep(); 156 | 157 | // deno-lint-ignore no-explicit-any 158 | target.addEventListener(CUSTOM_EVENT as any, (e: CustomEvent) => { 159 | assertEquals(e.detail, { foo: 123, bar: "baz" }); 160 | document.body.removeChild(el); 161 | p.resolve(); 162 | }); 163 | 164 | comp.dispatchEvent(new CustomEvent("foo")); 165 | await p; 166 | await clearComponents(); 167 | }, 168 | ); 169 | }); 170 | 171 | Deno.test("@pub(event, selector)", async (t) => { 172 | await t.step("publishes events to the given selector", async () => { 173 | const p = deferred(); 174 | const CUSTOM_EVENT = "foo-bar"; 175 | 176 | @component("component") 177 | // deno-lint-ignore no-unused-vars 178 | class Component { 179 | @pub(CUSTOM_EVENT, "#foo-bar-receiver") 180 | @on("foo") 181 | publish() { 182 | return { foo: 123, bar: "baz" }; 183 | } 184 | } 185 | 186 | const el = genel.div` 187 |
    188 |
    189 | `; 190 | document.body.appendChild(el); 191 | const target = el.querySelector(".target"); 192 | const comp = el.querySelector(".component"); 193 | 194 | prep(); 195 | 196 | // deno-lint-ignore no-explicit-any 197 | target!.addEventListener(CUSTOM_EVENT as any, (e: CustomEvent) => { 198 | assertEquals(e.detail, { foo: 123, bar: "baz" }); 199 | document.body.removeChild(el); 200 | p.resolve(); 201 | }); 202 | 203 | comp!.dispatchEvent(new CustomEvent("foo")); 204 | await p; 205 | await clearComponents(); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /src/decorators/sub.ts: -------------------------------------------------------------------------------- 1 | import is from "./is.ts"; 2 | 3 | export default (...args: string[]) => 4 | // deno-lint-ignore ban-types 5 | (Cls: Function) => { 6 | is(...args.map((event) => "sub:" + event))(Cls); 7 | }; 8 | -------------------------------------------------------------------------------- /src/decorators/sub_test.ts: -------------------------------------------------------------------------------- 1 | import sub from "./sub.ts"; 2 | import component from "./component.ts"; 3 | import make from "../make.ts"; 4 | import { assert, clearComponents } from "../test_helper.ts"; 5 | 6 | Deno.test("@sub(event)", async (t) => { 7 | await t.step("adds the class names to the element", async () => { 8 | @component("foo") 9 | @sub("bar") 10 | class Foo {} 11 | 12 | const el = document.createElement("div"); 13 | const coel = make("foo", el); 14 | 15 | assert(coel instanceof Foo); 16 | assert(el.classList.contains("sub:bar")); 17 | await clearComponents(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/decorators/wired.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wires the element of the given selector to the property. 3 | * 4 | * class A { 5 | * @wired('input') input: HTMLInputElement 6 | * 7 | * @on.click 8 | * onClick() { 9 | * axios.post('my-api', { value: this.input.value }) 10 | * } 11 | * } 12 | */ 13 | const wired = (sel: string) => 14 | // deno-lint-ignore no-explicit-any 15 | (_target: any, _key: string): any => { 16 | return { 17 | get() { 18 | return this.el.querySelector(sel); 19 | }, 20 | set() {}, 21 | }; 22 | }; 23 | 24 | /** 25 | * Wires all the elements to the property. 26 | * 27 | * class A { 28 | * @wired.all('li') items: HTMLElement 29 | * 30 | * @on.click 31 | * doEffect() { 32 | * this.items.forEach(li => { 33 | * li.classList.add('effect') 34 | * }) 35 | * } 36 | * } 37 | */ 38 | const wiredAll = (sel: string) => 39 | // deno-lint-ignore no-explicit-any 40 | (_target: any, _key: string): any => { 41 | return { 42 | get() { 43 | return this.el.querySelectorAll(sel); 44 | }, 45 | }; 46 | }; 47 | 48 | wired.all = wiredAll; 49 | 50 | export default wired; 51 | -------------------------------------------------------------------------------- /src/decorators/wired_test.ts: -------------------------------------------------------------------------------- 1 | import { def, make, wired } from "../mod.ts"; 2 | import { assertEquals, clearComponents, genel } from "../test_helper.ts"; 3 | 4 | Deno.test("@wired(selector)", async (t) => { 5 | await t.step("wires the element in the component", async () => { 6 | class Component { 7 | @wired(".elm") 8 | elm?: HTMLDivElement; 9 | } 10 | 11 | def("wire-el-test", Component); 12 | 13 | const el = genel.div` 14 |
    15 | `; 16 | 17 | const component = make("wire-el-test", el); 18 | assertEquals(component.elm!.nodeName, "DIV"); 19 | assertEquals(component.elm, el.firstChild); 20 | await clearComponents(); 21 | }); 22 | }); 23 | 24 | Deno.test("@wired.all(selector)", async (t) => { 25 | await t.step("wires the all elements in the component", async () => { 26 | class Component { 27 | @wired.all(".elm") 28 | elms?: HTMLElement[]; 29 | } 30 | 31 | def("comp", Component); 32 | 33 | const el = genel.div` 34 |
    35 |
    36 | `; 37 | 38 | const component = make("comp", el); 39 | assertEquals(component.elms!.length, 2); 40 | assertEquals(component.elms![0], el.firstChild); 41 | assertEquals(component.elms![1], el.lastChild); 42 | await clearComponents(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/def.ts: -------------------------------------------------------------------------------- 1 | import registry from "./registry.ts"; 2 | import prep from "./prep.ts"; 3 | import initComponent from "./init_component.ts"; 4 | 5 | import check from "./util/check.ts"; 6 | import { ready } from "./util/document.ts"; 7 | import { COELEMENT_DATA_KEY_PREFIX, COMPONENT_NAME_KEY } from "./util/const.ts"; 8 | import { addMountHook } from "./add_hidden_item.ts"; 9 | 10 | /** 11 | * Registers the class-component for the given name and constructor and returns the constructor. 12 | * @param name The component name 13 | * @param Constructor The constructor of the class component 14 | * @return The registered component class 15 | */ 16 | // deno-lint-ignore ban-types 17 | const def = (name: string, Constructor: Function) => { 18 | check( 19 | typeof name === "string", 20 | "`name` of a class component has to be a string.", 21 | ); 22 | check( 23 | typeof Constructor === "function", 24 | "`Constructor` of a class component has to be a function", 25 | ); 26 | // deno-lint-ignore no-explicit-any 27 | (Constructor as any)[COMPONENT_NAME_KEY] = name; 28 | const initClass = `${name}-💊`; 29 | 30 | // deno-lint-ignore no-explicit-any 31 | addMountHook(Constructor, (el: HTMLElement, coel: any) => { 32 | // deno-lint-ignore no-explicit-any 33 | (el as any)[COELEMENT_DATA_KEY_PREFIX + name] = coel; 34 | // FIXME(kt3k): the below can be written as .add(name, initClass) 35 | // when deno_dom fixes add class. 36 | el.classList.add(name); 37 | el.classList.add(initClass); 38 | }); 39 | 40 | /** 41 | * Initializes the html element by the configuration. 42 | * @param el The html element 43 | */ 44 | const initializer = (el: HTMLElement) => { 45 | if (!el.classList.contains(initClass)) { 46 | initComponent(Constructor, el); 47 | } 48 | }; 49 | 50 | // The selector 51 | initializer.sel = `.${name}:not(.${initClass})`; 52 | 53 | registry[name] = initializer; 54 | 55 | ready().then(() => { 56 | prep(name); 57 | }); 58 | }; 59 | 60 | export default def; 61 | -------------------------------------------------------------------------------- /src/def_test.ts: -------------------------------------------------------------------------------- 1 | import { def, make } from "./mod.ts"; 2 | import { assert, assertThrows, clearComponents } from "./test_helper.ts"; 3 | 4 | Deno.test("def", async (t) => { 5 | await t.step("throws an error when the first param is not a string", () => { 6 | assertThrows(() => { 7 | // deno-lint-ignore no-explicit-any 8 | def(null as any, class A {}); 9 | }, Error); 10 | }); 11 | 12 | await t.step( 13 | "throws an error when the second param is not a function", 14 | () => { 15 | assertThrows(() => { 16 | // deno-lint-ignore no-explicit-any 17 | def("register-test2", null as any); 18 | }, Error); 19 | }, 20 | ); 21 | 22 | await t.step("registers the given class by the given name component", () => { 23 | class A {} 24 | def("assign-test2", A); 25 | 26 | const el = document.createElement("div"); 27 | const coel = make("assign-test2", el); 28 | 29 | assert(coel instanceof A); 30 | }); 31 | 32 | await clearComponents(); 33 | }); 34 | -------------------------------------------------------------------------------- /src/dom_polyfill_deno.ts: -------------------------------------------------------------------------------- 1 | import { DOMParser } from "https://raw.githubusercontent.com/b-fuze/deno-dom/9e4814e8d8117d8410fabb726d118e144e49fbf3/deno-dom-wasm.ts"; 2 | // deno-lint-ignore no-explicit-any 3 | (globalThis as any).document = new DOMParser().parseFromString( 4 | "", 5 | "text/html", 6 | ); 7 | // deno-lint-ignore no-explicit-any 8 | (document as any).readyState = "complete"; 9 | -------------------------------------------------------------------------------- /src/dom_polyfill_node.ts: -------------------------------------------------------------------------------- 1 | import jsdom from "jsdom"; 2 | const { JSDOM } = jsdom; 3 | const dom = new JSDOM(`

    Hello world

    `); 4 | // deno-lint-ignore no-explicit-any 5 | (globalThis as any).document = dom.window.document; 6 | // deno-lint-ignore no-explicit-any 7 | (globalThis as any).CustomEvent = dom.window.CustomEvent; 8 | // deno-lint-ignore no-explicit-any 9 | (globalThis as any).Event = dom.window.Event; 10 | -------------------------------------------------------------------------------- /src/get.ts: -------------------------------------------------------------------------------- 1 | import check, { checkComponentNameIsValid } from "./util/check.ts"; 2 | import { COELEMENT_DATA_KEY_PREFIX } from "./util/const.ts"; 3 | 4 | /** 5 | * Gets the eoelement instance of the class-component of the given name 6 | * @param name The class-component name 7 | * @param el The element 8 | */ 9 | export default (name: string, el: Element): T => { 10 | checkComponentNameIsValid(name); 11 | 12 | // deno-lint-ignore no-explicit-any 13 | const coel = (el as any)[COELEMENT_DATA_KEY_PREFIX + name] as any; 14 | 15 | check(coel, `no coelement named: ${name}, on the dom: ${el.tagName}`); 16 | 17 | return coel; 18 | }; 19 | -------------------------------------------------------------------------------- /src/get_test.ts: -------------------------------------------------------------------------------- 1 | import { def, get, make } from "./mod.ts"; 2 | import { Foo } from "./test_fixture.ts"; 3 | import { assert, clearComponents } from "./test_helper.ts"; 4 | 5 | Deno.test("get", async (t) => { 6 | def("foo", Foo); 7 | 8 | await t.step("gets the coelement instance from the element", () => { 9 | const el = document.createElement("div"); 10 | 11 | make("foo", el); 12 | 13 | const coel = get("foo", el); 14 | 15 | assert(coel instanceof Foo); 16 | assert(coel.el === el); 17 | }); 18 | 19 | await clearComponents(); 20 | }); 21 | -------------------------------------------------------------------------------- /src/init_component.ts: -------------------------------------------------------------------------------- 1 | import { BEFORE_MOUNT_KEY } from "./util/const.ts"; 2 | 3 | /** 4 | * Initialize component by the class constructor. 5 | * @param Constructor The coelement class 6 | * @param el The element 7 | * @return The created coelement instance 8 | */ 9 | // deno-lint-ignore no-explicit-any 10 | export default (Constructor: any, el: HTMLElement): any => { 11 | const coel = new Constructor(); 12 | 13 | // Assigns element to coelement's .el property 14 | coel.el = el; 15 | 16 | // Initialize `before mount` hooks 17 | // This includes: 18 | // - initialization of event handlers 19 | // - initialization of innerHTML 20 | // - initialization of class names 21 | const list = Constructor[BEFORE_MOUNT_KEY]; 22 | if (Array.isArray(list)) { 23 | list.forEach((cb) => { 24 | cb(el, coel); 25 | }); 26 | } 27 | 28 | if (typeof coel.__mount__ === "function") { 29 | coel.__mount__(); 30 | } 31 | 32 | return coel; 33 | }; 34 | -------------------------------------------------------------------------------- /src/init_component_test.ts: -------------------------------------------------------------------------------- 1 | import * as capsid from "./mod.ts"; 2 | import initComponent from "./init_component.ts"; 3 | import { assert, assertEquals, clearComponents } from "./test_helper.ts"; 4 | 5 | const { on } = capsid; 6 | 7 | Deno.test("initComponent", async (t) => { 8 | await t.step( 9 | "initializes the element as a component by the given constructor", 10 | () => { 11 | class A {} 12 | 13 | const el = document.createElement("div"); 14 | const coel = initComponent(A, el); 15 | 16 | assertEquals(coel.el, el); 17 | assert(coel instanceof A); 18 | }, 19 | ); 20 | 21 | await t.step("calls __mount__", async () => { 22 | let resolve: () => void; 23 | const p = new Promise((r) => { 24 | resolve = r; 25 | }); 26 | class A { 27 | el?: HTMLElement; 28 | 29 | __mount__() { 30 | assertEquals(this.el, el); 31 | 32 | resolve(); 33 | } 34 | } 35 | 36 | const el = document.createElement("div"); 37 | 38 | initComponent(A, el); 39 | await p; 40 | }); 41 | 42 | await t.step("__mount__ runs after @on handlers are set", async () => { 43 | let resolve: () => void; 44 | const p = new Promise((r) => { 45 | resolve = r; 46 | }); 47 | class A { 48 | el?: HTMLElement; 49 | 50 | __mount__() { 51 | this.el!.dispatchEvent(new CustomEvent("click")); 52 | } 53 | 54 | @on.click 55 | onClick() { 56 | resolve(); 57 | } 58 | } 59 | 60 | initComponent(A, document.createElement("div")); 61 | await p; 62 | }); 63 | 64 | await clearComponents(); 65 | }); 66 | -------------------------------------------------------------------------------- /src/install.ts: -------------------------------------------------------------------------------- 1 | import def from "./def.ts"; 2 | import prep from "./prep.ts"; 3 | import make from "./make.ts"; 4 | import mount from "./mount.ts"; 5 | import unmount from "./unmount.ts"; 6 | import get from "./get.ts"; 7 | import { addMountHook } from "./add_hidden_item.ts"; 8 | import { 9 | component, 10 | emits, 11 | innerHTML, 12 | is, 13 | on, 14 | pub, 15 | sub, 16 | wired, 17 | } from "./decorators/index.ts"; 18 | import check from "./util/check.ts"; 19 | import __registry__ from "./registry.ts"; 20 | 21 | interface CapsidModule { 22 | // deno-lint-ignore ban-types 23 | install: Function; 24 | } 25 | 26 | /** 27 | * Installs the capsid module or plugin. 28 | * 29 | * @param capsidModule 30 | * @param options 31 | */ 32 | // deno-lint-ignore ban-types 33 | const install = (capsidModule: CapsidModule, options?: object) => { 34 | check( 35 | typeof capsidModule.install === "function", 36 | "The given capsid module does not have `install` method. Please check the install call.", 37 | ); 38 | 39 | capsidModule.install({ 40 | def, 41 | prep, 42 | make, 43 | mount, 44 | unmount, 45 | get, 46 | install, 47 | addMountHook, 48 | component, 49 | emits, 50 | innerHTML, 51 | is, 52 | on, 53 | pub, 54 | sub, 55 | wired, 56 | __registry__, 57 | }, options || {}); 58 | }; 59 | 60 | export default install; 61 | -------------------------------------------------------------------------------- /src/install_test.ts: -------------------------------------------------------------------------------- 1 | import * as capsid from "./mod.ts"; 2 | import { assertEquals, assertThrows } from "./test_helper.ts"; 3 | 4 | Deno.test("install", async (t) => { 5 | await t.step("calls install method of the given module", async () => { 6 | let resolve: () => void; 7 | const p = new Promise((r) => { 8 | resolve = r; 9 | }); 10 | const options = { foo: "bar" }; 11 | 12 | capsid.install( 13 | { 14 | install(capsidObj: unknown, options0: unknown) { 15 | assertEquals(capsidObj, capsid); 16 | assertEquals(options0, options); 17 | 18 | resolve(); 19 | }, 20 | }, 21 | options, 22 | ); 23 | await p; 24 | }); 25 | 26 | await t.step( 27 | "throws when the given module does not have the install method", 28 | () => { 29 | assertThrows( 30 | () => { 31 | // deno-lint-ignore no-explicit-any 32 | capsid.install({} as any); 33 | }, 34 | Error, 35 | "The given capsid module does not have `install` method. Please check the install call.", 36 | ); 37 | }, 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /src/make.ts: -------------------------------------------------------------------------------- 1 | import get from "./get.ts"; 2 | import { checkComponentNameIsValid } from "./util/check.ts"; 3 | import registry from "./registry.ts"; 4 | 5 | /** 6 | * Initializes the given element as the class-component. 7 | * @param name The name of the class component 8 | * @param el The element to initialize 9 | * @return 10 | */ 11 | export default (name: string, el: HTMLElement) => { 12 | checkComponentNameIsValid(name); 13 | 14 | registry[name](el); 15 | 16 | return get(name, el); 17 | }; 18 | -------------------------------------------------------------------------------- /src/make_test.ts: -------------------------------------------------------------------------------- 1 | import { def, get, make } from "./mod.ts"; 2 | import { Foo } from "./test_fixture.ts"; 3 | import { assert, assertEquals, clearComponents } from "./test_helper.ts"; 4 | 5 | Deno.test("make", async (t) => { 6 | def("foo", Foo); 7 | 8 | await t.step( 9 | "initializes the element as an class-component of the given name", 10 | () => { 11 | const el = document.createElement("div"); 12 | 13 | make("foo", el); 14 | 15 | assert(el.getAttribute("is_foo") === "true"); 16 | }, 17 | ); 18 | 19 | await t.step("returns an instance of coelement", () => { 20 | assert(make("foo", document.createElement("div")) instanceof Foo); 21 | }); 22 | 23 | await t.step("doesn't initialize element twice", () => { 24 | let a = 0; 25 | class A { 26 | __mount__() { 27 | a++; 28 | } 29 | } 30 | def("bar", A); 31 | 32 | const el = document.createElement("div"); 33 | make("bar", el); 34 | make("bar", el); 35 | 36 | assertEquals(a, 1); 37 | }); 38 | 39 | await t.step( 40 | "in __mount__, it can get component instance from el by the name", 41 | async () => { 42 | let resolve: () => void; 43 | const p = new Promise((r) => { 44 | resolve = r; 45 | }); 46 | 47 | class Component { 48 | el?: HTMLElement; 49 | 50 | __mount__() { 51 | assertEquals(get("bar", this.el!), this); 52 | 53 | resolve(); 54 | } 55 | } 56 | 57 | def("bar", Component); 58 | 59 | make("bar", document.createElement("div")); 60 | await p; 61 | }, 62 | ); 63 | 64 | await clearComponents(); 65 | }); 66 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | import def from "./def.ts"; 2 | import prep from "./prep.ts"; 3 | import make from "./make.ts"; 4 | import mount from "./mount.ts"; 5 | import unmount from "./unmount.ts"; 6 | import get from "./get.ts"; 7 | import install from "./install.ts"; 8 | import { addMountHook } from "./add_hidden_item.ts"; 9 | import { 10 | component, 11 | emits, 12 | innerHTML, 13 | is, 14 | on, 15 | pub, 16 | sub, 17 | wired, 18 | } from "./decorators/index.ts"; 19 | import __registry__ from "./registry.ts"; 20 | 21 | export { 22 | __registry__, 23 | addMountHook, 24 | component, 25 | def, 26 | emits, 27 | get, 28 | innerHTML, 29 | install, 30 | is, 31 | make, 32 | mount, 33 | on, 34 | prep, 35 | pub, 36 | sub, 37 | unmount, 38 | wired, 39 | }; 40 | -------------------------------------------------------------------------------- /src/mount.ts: -------------------------------------------------------------------------------- 1 | import mount from "./init_component.ts"; 2 | 3 | export default mount; 4 | -------------------------------------------------------------------------------- /src/mount_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "./test_helper.ts"; 2 | import { mount } from "./mod.ts"; 3 | 4 | Deno.test("mount initializes the element by the given component class", async () => { 5 | let resolve: () => void; 6 | const p = new Promise((r) => { 7 | resolve = r; 8 | }); 9 | 10 | class Component { 11 | el?: HTMLElement; 12 | 13 | __mount__() { 14 | assertEquals(this.el, div); 15 | resolve(); 16 | } 17 | } 18 | 19 | const div = document.createElement("div"); 20 | 21 | mount(Component, div); 22 | await p; 23 | }); 24 | -------------------------------------------------------------------------------- /src/plugins/debug_plugin.ts: -------------------------------------------------------------------------------- 1 | import { COMPONENT_NAME_KEY } from "../util/const.ts"; 2 | 3 | const install = () => { 4 | // deno-lint-ignore no-explicit-any 5 | (globalThis as any).capsidDebugMessage = (message: any) => { 6 | switch (message.type) { 7 | case "event": 8 | onEventMessage(message); 9 | break; 10 | default: 11 | console.log(`Unknown message: ${JSON.stringify(message)}`); 12 | } 13 | }; 14 | }; 15 | 16 | /** 17 | * Gets the bold colored style. 18 | */ 19 | const boldColor = (color: string): string => 20 | `color: ${color}; font-weight: bold;`; 21 | 22 | /** 23 | * Gets the displayable component name. 24 | */ 25 | // deno-lint-ignore no-explicit-any 26 | const getComponentName = (coel: any): string => { 27 | const { constructor } = coel; 28 | return `${constructor[COMPONENT_NAME_KEY] || constructor.name}`; 29 | }; 30 | 31 | const defaultEventColor = "#f012be"; 32 | 33 | const onEventMessage = ({ 34 | coel, 35 | e, 36 | module, 37 | color, 38 | }: { 39 | // deno-lint-ignore no-explicit-any 40 | coel: any; 41 | e: Event; 42 | module: string; 43 | color?: string; 44 | }) => { 45 | const event = e.type; 46 | const component = getComponentName(coel); 47 | 48 | console.groupCollapsed( 49 | `${module}> %c${event}%c on %c${component}`, 50 | boldColor(color || defaultEventColor), 51 | "", 52 | boldColor("#1a80cc"), 53 | ); 54 | console.log(e); 55 | 56 | if (e.target) { 57 | console.log(e.target); 58 | } 59 | 60 | if (coel.el) { 61 | console.log(coel.el); 62 | } 63 | 64 | console.groupEnd(); 65 | }; 66 | 67 | export default { install }; 68 | -------------------------------------------------------------------------------- /src/plugins/debug_plugin_test.ts: -------------------------------------------------------------------------------- 1 | import { install } from "../mod.ts"; 2 | import debugPlugin from "./debug_plugin.ts"; 3 | import { td } from "../test_helper.ts"; 4 | 5 | Deno.test("debug-plugin", async (t) => { 6 | const afterEach = () => { 7 | td.reset(); 8 | // deno-lint-ignore no-explicit-any 9 | delete (globalThis as any).capsidDebugMessage; 10 | }; 11 | 12 | await t.step("logs event and component names with event type message", () => { 13 | install(debugPlugin); 14 | 15 | const el = document.createElement("a"); 16 | const e = { type: "click", target: el }; 17 | const coel = { constructor: { name: "foo" }, el }; 18 | 19 | td.replace(console, "groupCollapsed"); 20 | td.replace(console, "log"); 21 | td.replace(console, "groupEnd"); 22 | // deno-lint-ignore no-explicit-any 23 | (globalThis as any).capsidDebugMessage({ 24 | type: "event", 25 | e, 26 | coel, 27 | module: "module", 28 | }); 29 | 30 | td.verify( 31 | console.groupCollapsed( 32 | "module> %cclick%c on %cfoo", 33 | "color: #f012be; font-weight: bold;", 34 | "", 35 | "color: #1a80cc; font-weight: bold;", 36 | ), 37 | ); 38 | td.verify(console.log(e)); 39 | td.verify(console.groupEnd()); 40 | 41 | afterEach(); 42 | }); 43 | 44 | await t.step("logs error message with unknown type message", () => { 45 | install(debugPlugin); 46 | 47 | td.replace(console, "log"); 48 | // deno-lint-ignore no-explicit-any 49 | (globalThis as any).capsidDebugMessage({ type: "unknown" }); 50 | 51 | td.verify( 52 | console.log(`Unknown message: ${JSON.stringify({ type: "unknown" })}`), 53 | ); 54 | 55 | afterEach(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/plugins/outside_events_plugin.ts: -------------------------------------------------------------------------------- 1 | import debugMessage from "../util/debug_message.ts"; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | const install = (capsid: any) => { 5 | const { on, addMountHook } = capsid; 6 | 7 | on.outside = (event: string) => 8 | // deno-lint-ignore no-explicit-any 9 | (target: any, key: string, _: any) => { 10 | // deno-lint-ignore no-explicit-any 11 | addMountHook(target.constructor, (el: HTMLElement, coel: any) => { 12 | const listener = (e: Event): void => { 13 | // deno-lint-ignore no-explicit-any 14 | if (el !== e.target && !el.contains(e.target as any)) { 15 | // TODO(kt3k): selectively inject __DEV__ variable 16 | const __DEV__ = true; 17 | if (__DEV__) { 18 | debugMessage({ 19 | type: "event", 20 | module: "outside-events", 21 | color: "#39cccc", 22 | el, 23 | e, 24 | coel, 25 | }); 26 | } 27 | 28 | coel[key](e); 29 | } 30 | }; 31 | 32 | document.addEventListener(event, listener); 33 | }); 34 | }; 35 | }; 36 | 37 | export default { install }; 38 | -------------------------------------------------------------------------------- /src/plugins/outside_events_plugin_test.ts: -------------------------------------------------------------------------------- 1 | import "../test_helper.ts"; 2 | import outsideEventsPlugin from "./outside_events_plugin.ts"; 3 | import { install, mount, on } from "../mod.ts"; 4 | 5 | Deno.test("outside-events-plugin", async (t) => { 6 | install(outsideEventsPlugin); 7 | 8 | await t.step("on.outside adds outside event handler", async () => { 9 | let resolve: () => void | undefined; 10 | const p = new Promise((r) => { 11 | resolve = r; 12 | }); 13 | class Component { 14 | @on.outside("click") 15 | handleOutsideClick() { 16 | resolve(); 17 | } 18 | } 19 | 20 | const div = document.createElement("div"); 21 | document.body.appendChild(div); 22 | 23 | mount(Component, div); 24 | 25 | document.dispatchEvent(new Event("click")); 26 | await p; 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/prep.ts: -------------------------------------------------------------------------------- 1 | import { checkComponentNameIsValid } from "./util/check.ts"; 2 | import registry from "./registry.ts"; 3 | 4 | /** 5 | * Initializes the class components of the given name in the range of the given element. 6 | * @param name The class name 7 | * @param el The dom where class componets are initialized 8 | * @throws when the class name is invalid type. 9 | */ 10 | export default (name?: string | null, el?: Element): void => { 11 | let classNames; 12 | 13 | if (!name) { 14 | classNames = Object.keys(registry); 15 | } else { 16 | checkComponentNameIsValid(name); 17 | 18 | classNames = [name]; 19 | } 20 | 21 | classNames.map((className) => { 22 | [].map.call( 23 | (el || document).querySelectorAll(registry[className].sel), 24 | registry[className], 25 | ); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/prep_test.ts: -------------------------------------------------------------------------------- 1 | import { def, prep } from "./mod.ts"; 2 | import { Foo } from "./test_fixture.ts"; 3 | import { assertEquals, assertThrows, clearComponents } from "./test_helper.ts"; 4 | 5 | Deno.test("prep", async (t) => { 6 | def("foo", Foo); 7 | def("foo-2", Foo); 8 | 9 | const clear = () => { 10 | if (document.body) { 11 | document.body.innerHTML = ""; 12 | } 13 | }; 14 | 15 | await t.step("initializes the class component of the given name", () => { 16 | clear(); 17 | const el = document.createElement("div"); 18 | el.setAttribute("class", "foo"); 19 | 20 | if (document.body) { 21 | document.body.appendChild(el); 22 | } 23 | 24 | prep("foo"); 25 | 26 | assertEquals(el.getAttribute("is_foo"), "true"); 27 | }); 28 | 29 | await t.step("initializes all when call with empty args", () => { 30 | const el = document.createElement("div"); 31 | el.setAttribute("class", "foo"); 32 | 33 | const el2 = document.createElement("div"); 34 | el2.setAttribute("class", "foo-2"); 35 | 36 | if (document.body) { 37 | document.body.appendChild(el); 38 | document.body.appendChild(el2); 39 | } 40 | 41 | prep(); 42 | 43 | assertEquals(el.getAttribute("is_foo"), "true"); 44 | assertEquals(el2.getAttribute("is_foo"), "true"); 45 | }); 46 | 47 | await t.step( 48 | "throws an error when the given name of class-component is not registered", 49 | () => { 50 | assertThrows(() => { 51 | prep("does-not-exist"); 52 | }, Error); 53 | }, 54 | ); 55 | 56 | await clearComponents(); 57 | }); 58 | -------------------------------------------------------------------------------- /src/registry.ts: -------------------------------------------------------------------------------- 1 | interface Initializer { 2 | // deno-lint-ignore no-explicit-any 3 | (el: HTMLElement, coel?: any): void; 4 | sel: string; 5 | } 6 | interface RegistryType { 7 | [key: string]: Initializer; 8 | } 9 | 10 | /** 11 | * The registry of component initializers. 12 | */ 13 | const registry: RegistryType = {}; 14 | 15 | export default registry; 16 | -------------------------------------------------------------------------------- /src/td_deno.ts: -------------------------------------------------------------------------------- 1 | import "https://unpkg.com/testdouble@3.16.3/dist/testdouble.js"; 2 | 3 | // deno-lint-ignore no-explicit-any 4 | const td = (globalThis as any).td; 5 | export { td }; 6 | -------------------------------------------------------------------------------- /src/td_node.ts: -------------------------------------------------------------------------------- 1 | import * as td from "testdouble"; 2 | 3 | export { td }; 4 | -------------------------------------------------------------------------------- /src/test_fixture.ts: -------------------------------------------------------------------------------- 1 | export class Foo { 2 | el?: HTMLElement; 3 | 4 | __mount__() { 5 | this.el!.setAttribute("is_foo", "true"); 6 | } 7 | } 8 | 9 | export class Bar { 10 | el?: HTMLElement; 11 | 12 | __mount__() { 13 | this.el!.setAttribute("is_bar", "true"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test_helper.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | assertThrows, 5 | } from "https://deno.land/std@0.119.0/testing/asserts.ts"; 6 | export { deferred } from "https://deno.land/std@0.119.0/async/deferred.ts"; 7 | import genel_ from "https://esm.sh/genel"; 8 | import * as capsid from "./mod.ts"; 9 | import "./dom_polyfill_deno.ts"; 10 | export { td } from "./td_deno.ts"; 11 | import { ready } from "./util/document.ts"; 12 | 13 | // deno-lint-ignore no-explicit-any 14 | const genel = genel_ as any; 15 | export { genel }; 16 | 17 | export async function clearComponents() { 18 | await ready(); 19 | Object.keys(capsid.__registry__).forEach((key) => { 20 | delete capsid.__registry__[key]; 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/unmount.ts: -------------------------------------------------------------------------------- 1 | import get from "./get.ts"; 2 | import { 3 | COELEMENT_DATA_KEY_PREFIX, 4 | KEY_EVENT_LISTENERS, 5 | } from "./util/const.ts"; 6 | 7 | export default (name: string, el: HTMLElement): void => { 8 | const coel = get(name, el); 9 | 10 | // @ts-ignore use coel.__unmount__ 11 | if (typeof coel.__unmount__ === "function") { 12 | // @ts-ignore use coel.__unmout__ 13 | coel.__unmount__(); 14 | } 15 | 16 | el.classList.remove(name, `${name}-💊`); 17 | // deno-lint-ignore no-explicit-any 18 | ((coel as any)[KEY_EVENT_LISTENERS] || []).forEach((listener: any) => { 19 | listener.remove(); 20 | }); 21 | 22 | // deno-lint-ignore no-explicit-any 23 | delete (el as any)[COELEMENT_DATA_KEY_PREFIX + name]; 24 | // @ts-ignore use coel.el 25 | delete coel.el; 26 | }; 27 | -------------------------------------------------------------------------------- /src/unmount_test.ts: -------------------------------------------------------------------------------- 1 | import { def, get, make, on, unmount } from "./mod.ts"; 2 | import { assert, assertEquals, clearComponents, genel } from "./test_helper.ts"; 3 | 4 | Deno.test("unmount", async (t) => { 5 | await t.step("removes class name, reference and event handlers", async () => { 6 | class Foo { 7 | el?: Element; 8 | 9 | @on.click 10 | @on("foo") 11 | method() { 12 | throw new Error("event handler called!"); 13 | } 14 | } 15 | 16 | def("foo", Foo); 17 | 18 | const el = genel.div``; 19 | const coel = make("foo", el); 20 | 21 | assert(el.classList.contains("foo")); 22 | assertEquals(coel.el, el); 23 | assertEquals(get("foo", el), coel); 24 | 25 | unmount("foo", el); 26 | 27 | assert(!el.classList.contains("foo")); 28 | assertEquals(coel.el, undefined); 29 | 30 | el.dispatchEvent(new Event("click")); 31 | el.dispatchEvent(new CustomEvent("foo")); 32 | 33 | await new Promise((resolve) => setTimeout(() => resolve(), 100)); 34 | await clearComponents(); 35 | }); 36 | 37 | await t.step( 38 | "unmounts anscestor class's event handler correctly", 39 | async () => { 40 | class Foo { 41 | @on.click 42 | @on("foo") 43 | method() { 44 | throw new Error("event handler called!"); 45 | } 46 | } 47 | 48 | class Bar extends Foo {} 49 | 50 | def("bar", Bar); 51 | 52 | const el = genel.div``; 53 | make("bar", el); 54 | 55 | unmount("bar", el); 56 | 57 | el.dispatchEvent(new Event("click")); 58 | el.dispatchEvent(new CustomEvent("foo")); 59 | 60 | await new Promise((resolve) => setTimeout(() => resolve(), 100)); 61 | await clearComponents(); 62 | }, 63 | ); 64 | 65 | await t.step("calls __unmount__ if exists", async () => { 66 | let resolve: () => void | undefined; 67 | const p = new Promise((r) => { 68 | resolve = r; 69 | }); 70 | class Foo { 71 | __unmount__() { 72 | resolve(); 73 | } 74 | } 75 | 76 | def("foo", Foo); 77 | 78 | const el = genel.div``; 79 | 80 | make("foo", el); 81 | 82 | unmount("foo", el); 83 | await p; 84 | await clearComponents(); 85 | }); 86 | 87 | await t.step( 88 | "does not unmount listeners of different component which mounted on the same element", 89 | async () => { 90 | let resolve: () => void | undefined; 91 | const p = new Promise((r) => { 92 | resolve = r; 93 | }); 94 | class Foo {} 95 | class Bar { 96 | @on.click 97 | method() { 98 | resolve(); 99 | } 100 | } 101 | 102 | def("foo", Foo); 103 | def("bar", Bar); 104 | 105 | const el = genel.div``; 106 | 107 | make("foo", el); 108 | make("bar", el); 109 | unmount("foo", el); 110 | 111 | el.dispatchEvent(new Event("click")); 112 | await p; 113 | await clearComponents(); 114 | }, 115 | ); 116 | }); 117 | -------------------------------------------------------------------------------- /src/util/check.ts: -------------------------------------------------------------------------------- 1 | import registry from "../registry.ts"; 2 | /** 3 | * Asserts the given condition holds, otherwise throws. 4 | * @param assertion The assertion expression 5 | * @param message The assertion message 6 | */ 7 | export default function check(assertion: boolean, message: string): void { 8 | if (!assertion) { 9 | throw new Error(message); 10 | } 11 | } 12 | 13 | /** 14 | * Asserts the given name is a valid component name. 15 | * @param name The component name 16 | */ 17 | // deno-lint-ignore no-explicit-any 18 | export function checkComponentNameIsValid(name: any): void { 19 | check(typeof name === "string", "The name should be a string"); 20 | check( 21 | !!registry[name], 22 | `The coelement of the given name is not registered: ${name}`, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/util/const.ts: -------------------------------------------------------------------------------- 1 | export const COELEMENT_DATA_KEY_PREFIX = "C$"; 2 | export const KEY_EVENT_LISTENERS = "K$"; 3 | export const COMPONENT_NAME_KEY = "N$"; 4 | export const IS_KEY = "S$"; 5 | export const BEFORE_MOUNT_KEY = "B$"; 6 | -------------------------------------------------------------------------------- /src/util/debug_message.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore no-explicit-any 2 | declare let capsidDebugMessage: any; 3 | 4 | export default (message: unknown) => { 5 | if (typeof capsidDebugMessage === "function") { 6 | capsidDebugMessage(message); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/util/document.ts: -------------------------------------------------------------------------------- 1 | const READY_STATE_CHANGE = "readystatechange"; 2 | 3 | let p: Promise; 4 | export function ready() { 5 | return p = p || new Promise((resolve) => { 6 | const doc = document; 7 | const checkReady = () => { 8 | if (doc.readyState === "complete") { 9 | resolve(); 10 | doc.removeEventListener(READY_STATE_CHANGE, checkReady); 11 | } 12 | }; 13 | 14 | doc.addEventListener(READY_STATE_CHANGE, checkReady); 15 | 16 | checkReady(); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/util/event_trigger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Triggers the event to the given elements. 3 | * @param el The element 4 | * @param type The event type 5 | * @param detail The optional detail object 6 | */ 7 | export const triggerToElements = ( 8 | elements: HTMLElement[], 9 | type: string, 10 | bubbles: boolean, 11 | // deno-lint-ignore no-explicit-any 12 | result: any, 13 | ) => { 14 | // deno-lint-ignore no-explicit-any 15 | const emit = (r: any) => { 16 | elements.forEach((el) => { 17 | el.dispatchEvent(new CustomEvent(type, { detail: r, bubbles })); 18 | }); 19 | }; 20 | if (result && result.then) { 21 | result.then(emit); 22 | } else { 23 | emit(result); 24 | } 25 | }; 26 | --------------------------------------------------------------------------------