├── .bmp.yml ├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── capsule-logo.svg ├── deno.json ├── deploy.ts ├── dist.js ├── dist.min.js ├── dom_polyfill_deno.ts ├── dom_polyfill_node.ts ├── index.html ├── loader.js ├── mod.ts ├── style.css ├── test.ts └── util.ts /.bmp.yml: -------------------------------------------------------------------------------- 1 | version: 0.6.1 2 | commit: 'chore: bump to v%.%.%' 3 | files: 4 | README.md: 5 | - Capsule v%.%.% 6 | - 'https://deno.land/x/capsule@v%.%.%/mod.ts' 7 | - 'https://raw.githubusercontent.com/capsidjs/capsule/v%.%.%/dist.min.js' 8 | - 'https://deno.land/x/capsule@v%.%.%/dist.min.js' 9 | - 'https://deno.land/x/capsule@v%.%.%/loader.js' 10 | mod.ts: Capsule v%.%.% 11 | deno.json: '"version": "%.%.%"' 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | [*] 3 | indent_style=space 4 | indent_size=2 5 | trim_trailing_whitespace=true 6 | insert_final_newline=true 7 | [Makefile] 8 | indent_style=tab 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: main 5 | pull_request: 6 | branches: main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: denoland/setup-deno@v1 13 | - run: deno fmt --check 14 | - run: deno lint 15 | - run: deno task test 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 30 9 | permissions: 10 | contents: read 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: denoland/setup-deno@v1 15 | - run: deno task test 16 | - run: deno publish 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /lcov.info 3 | /node 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "denoland.vscode-deno" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | Copyright © 2022 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | capsule 2 | 3 | # Capsule v0.6.1 4 | 5 | [![ci](https://github.com/capsidjs/capsule/actions/workflows/ci.yml/badge.svg)](https://github.com/capsidjs/capsule/actions/workflows/ci.yml) 6 | 7 | > Event-driven DOM programming in a new style 8 | 9 | # Features 10 | 11 | - Supports **event-driven** style of frontend programming in a **new way**. 12 | - Supports **event delegation** and **outside events** out of the box. 13 | - **Lightweight** library. 14 | [**1.25 kiB**](https://raw.githubusercontent.com/capsidjs/capsule/v0.6.1/dist.min.js) 15 | gzipped. **No dependencies**. **No build** steps. 16 | - Uses **plain JavaScript** and **plain HTML**, requires **No special syntax**. 17 | - **TypeScript** friendly. 18 | 19 | [See live examples](https://capsule.deno.dev/) 20 | 21 | # Motivation 22 | 23 | Virtual DOM frameworks are good for many use cases, but sometimes they are 24 | overkill for the use cases where you only need a little bit of event handlers 25 | and dom modifications. 26 | 27 | This `capsule` library explores the new way of simple event-driven DOM 28 | programming without virtual dom. 29 | 30 | # Slogans 31 | 32 | - Local query is good. Global query is bad. 33 | - Define behaviors based on HTML classes. 34 | - Use pubsub when making remote effect. 35 | 36 | ## Local query is good. Global query is bad 37 | 38 | When people use jQuery, they often do: 39 | 40 | ```js 41 | $(".some-class").each(function () { 42 | $(this).on("some-event", () => { 43 | $(".some-target").each(function () { 44 | // some effects on this element 45 | }); 46 | }); 47 | }); 48 | ``` 49 | 50 | This is very common pattern, and this is very bad. 51 | 52 | The above code can been seen as a behavior of `.some-class` elements, and they 53 | use global query `$(".some-target")`. Because they use global query here, they 54 | depend on the entire DOM tree of the page. If the page change anything in it, 55 | the behavior of the above code can potentially be changed. 56 | 57 | This is so unpredictable because any change in the page can affect the behavior 58 | of the above class. You can predict what happens with the above code only when 59 | you understand every details of the entire application, and that's often 60 | impossible when the application is large size, and multiple people working on 61 | that app. 62 | 63 | So how to fix this? We recommend you should use **local** queries. 64 | 65 | Let's see this example: 66 | 67 | ```js 68 | $(".some-class").each(function () { 69 | $(this).on("some-event", () => { 70 | $(this).find(".some-target").each(function () { 71 | // some effects on this element 72 | }); 73 | }); 74 | }); 75 | ``` 76 | 77 | The difference is `$(this).find(".some-target")` part. This selects the elements 78 | only under each `.some-class` element. So this code only depends on the elements 79 | inside it, which means there is no global dependencies here. 80 | 81 | `capsule` enforces this pattern by providing `query` function to event handlers 82 | which only finds elements under the given element. 83 | 84 | ```js 85 | const { on } = component("some-class"); 86 | 87 | on.click = ({ query }) => { 88 | query(".some-target").textContent = "clicked"; 89 | }; 90 | ``` 91 | 92 | Here `query` is the alias of `el.querySelector` and it finds `.some-target` only 93 | under it. So the dependency is **local** here. 94 | 95 | ## Define behaviors based on HTML classes 96 | 97 | From our observation, skilled jQuery developers always define DOM behaviors 98 | based on HTML classes. 99 | 100 | We borrowed this pattern, and `capsule` allows you to define behavior only based 101 | on HTML classes, not random combination of query selectors. 102 | 103 | ```html 104 |
John Doe
105 | ``` 106 | 107 | ```js 108 | const { on } = component("hello"); 109 | 110 | on.__mount__ = () => { 111 | alert(`Hello, I'm ${el.textContext}!`); // Alerts "Hello, I'm John Doe!" 112 | }; 113 | ``` 114 | 115 | ## Use pubsub when making remote effect 116 | 117 | We generally recommend using only local queries, but how to make effects to the 118 | remote elements? 119 | 120 | We reommend using pubsub pattern here. By using this pattern, you can decouple 121 | those affecting and affected elements. If you decouple those elements, you can 122 | test those components independently by using events as I/O of those components. 123 | 124 | `capsule` library provides `pub` and `sub` APIs for encouraging this pattern. 125 | 126 | ```js 127 | const EVENT = "my-event"; 128 | { 129 | const { on } = component("publisher"); 130 | 131 | on.click = ({ pub }) => { 132 | pub(EVENT); 133 | }; 134 | } 135 | 136 | { 137 | const { on, sub } = component("subscriber"); 138 | 139 | sub(EVENT); 140 | 141 | on[EVENT] = () => { 142 | alert(`Got ${EVENT}!`); 143 | }; 144 | } 145 | ``` 146 | 147 | Note: `capsule` uses DOM Event as event payload, and `sub:EVENT` HTML class as 148 | registration to the event. When `pub(EVENT)` is called the CustomEvent of 149 | `EVENT` type are dispatched to the elements which have `sub:EVENT` class. 150 | 151 | ## TodoMVC 152 | 153 | TodoMVC implementation is also available 154 | [here](https://github.com/capsidjs/capsule-todomvc). 155 | 156 | ## Live examples 157 | 158 | See [the live demos](https://capsule.deno.dev/). 159 | 160 | # Install 161 | 162 | Vanilla js (ES Module): 163 | 164 | ```html 165 | 169 | ``` 170 | 171 | Vanilla js (Legacy script tag): 172 | 173 | ```html 174 | 175 | 181 | ``` 182 | 183 | Deno: 184 | 185 | ```js 186 | import { component } from "https://deno.land/x/capsule@v0.6.1/mod.ts"; 187 | ``` 188 | 189 | Via npm: 190 | 191 | ``` 192 | npm install @kt3k/capsule 193 | ``` 194 | 195 | and 196 | 197 | ```js 198 | import { component } from "@kt3k/capsule"; 199 | ``` 200 | 201 | # Examples 202 | 203 | Mirrors input value of `` element to another dom. 204 | 205 | ```js 206 | import { component } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 207 | 208 | const { on } = component("mirroring"); 209 | 210 | on.input = ({ query }) => { 211 | query(".src").textContent = query(".dest").value; 212 | }; 213 | ``` 214 | 215 | Pubsub. 216 | 217 | ```js 218 | import { component } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 219 | 220 | const EVENT = "my-event"; 221 | 222 | { 223 | const { on } = component("pub-element"); 224 | 225 | on.click = ({ pub }) => { 226 | pub(EVENT, { hello: "world!" }); 227 | }; 228 | } 229 | 230 | { 231 | const { on, sub } = component("sub-element"); 232 | 233 | sub(EVENT); 234 | 235 | on[EVENT] = ({ e }) => { 236 | console.log(e.detail.hello); // => world! 237 | }; 238 | } 239 | ``` 240 | 241 | Mount hooks. 242 | 243 | ```js 244 | import { component } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 245 | 246 | const { on } = component("my-component"); 247 | 248 | // __mount__ handler is called when the component mounts to the elements. 249 | on.__mount__ = () => { 250 | console.log("hello, I'm mounted"); 251 | }; 252 | ``` 253 | 254 | Prevent default, stop propagation. 255 | 256 | ```js 257 | import { component } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 258 | 259 | const { on } = component("my-component"); 260 | 261 | on.click = ({ e }) => { 262 | // e is the native event object. 263 | // You can call methods of Event object 264 | e.stopPropagation(); 265 | e.preventDefault(); 266 | console.log("hello, I'm mounted"); 267 | }; 268 | ``` 269 | 270 | Event delegation. You can assign handlers to `on(selector).event` to use 271 | [event delegation](https://www.geeksforgeeks.org/event-delegation-in-javascript/) 272 | pattern. 273 | 274 | ```js 275 | import { component } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 276 | 277 | const { on } = component("my-component"); 278 | 279 | on(".btn").click = ({ e }) => { 280 | console.log(".btn is clicked!"); 281 | }; 282 | ``` 283 | 284 | Outside event handler. By assigning `on.outside.event`, you can handle the event 285 | outside of the component dom. 286 | 287 | ```js 288 | import { component } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 289 | 290 | const { on } = component("my-component"); 291 | 292 | on.outside.click = ({ e }) => { 293 | console.log("The outside of my-component has been clicked!"); 294 | }; 295 | ``` 296 | 297 | # API reference 298 | 299 | ```ts 300 | const { component, mount } from "https://deno.land/x/capsule@v0.6.1/dist.min.js"; 301 | ``` 302 | 303 | ## `component(name): ComponentResult` 304 | 305 | This registers the component of the given name. This returns a `ComponentResult` 306 | which has the following shape. 307 | 308 | ```ts 309 | interface ComponentResult { 310 | on: EventRegistryProxy; 311 | is(name: string); 312 | sub(type: string); 313 | innerHTML(html: string); 314 | } 315 | 316 | interface EventRegistry { 317 | [key: string]: EventHandler | {}; 318 | (selector: string): { 319 | [key: string]: EventHandler; 320 | }; 321 | outside: { 322 | [key: string]: EventHandler; 323 | }; 324 | } 325 | ``` 326 | 327 | ## `component().on[eventName] = EventHandler` 328 | 329 | You can register event handler by assigning to `on.event`. 330 | 331 | ```ts 332 | const { on } = component("my-component"); 333 | 334 | on.click = () => { 335 | alert("clicked"); 336 | }; 337 | ``` 338 | 339 | ## `component().on(selector)[eventName] = EventHandler` 340 | 341 | You can register event handler by assigning to `on(selector).event`. 342 | 343 | The actual event handler is attached to the component dom (the root of element 344 | which this component mounts), but the handler is only triggered when the target 345 | is inside the given `selector`. 346 | 347 | ```ts 348 | const { on } = component("my-component"); 349 | 350 | on(".btn").click = () => { 351 | alert(".btn is clicked"); 352 | }; 353 | ``` 354 | 355 | ## `component().on.outside[eventName] = EventHandler` 356 | 357 | You can register event handler for the outside of the component dom by assigning 358 | to `on.outside.event` 359 | 360 | ```ts 361 | const { on } = component("my-component"); 362 | 363 | on.outside.click = () => { 364 | console.log("outside of the component has been clicked!"); 365 | }; 366 | ``` 367 | 368 | This is useful for implementing a tooltip which closes itself if the outside of 369 | it is clicked. 370 | 371 | ## `component().is(name: string)` 372 | 373 | `is(name)` sets the html class to the component dom at `mount` phase. 374 | 375 | ```ts 376 | const { is } = component("my-component"); 377 | 378 | is("my-class-name"); 379 | ``` 380 | 381 | ## `component().innerHTML(html: string)` 382 | 383 | `innerHTML(html)` sets the inner html to the component dom at `mount` phase. 384 | 385 | ```ts 386 | const { innerHTML } = component("my-component"); 387 | 388 | innerHTML("

Greetings!

Hello from my-component

"); 389 | ``` 390 | 391 | ## `component().sub(type: string)` 392 | 393 | `sub(type)` sets the html class of the form `sub:type` to the component at 394 | `mount` phase. By adding `sub:type` class, the component can receive the event 395 | from `pub(type)` calls. 396 | 397 | ```ts 398 | { 399 | const { sub, on } = component("my-component"); 400 | sub("my-event"); 401 | on["my-event"] = () => { 402 | alert("Got my-event"); 403 | }; 404 | } 405 | { 406 | const { on } = component("another-component"); 407 | on.click = ({ pub }) => { 408 | pub("my-event"); 409 | }; 410 | } 411 | ``` 412 | 413 | ## `EventHandler` 414 | 415 | The event handler in `capsule` has the following signature. The first argument 416 | is `EventHandlerContext`, not `Event`. 417 | 418 | ```ts 419 | type EventHandler = (ctx: ComponentEventContext) => void; 420 | ``` 421 | 422 | ## `ComponentEventContext` 423 | 424 | ```ts 425 | interface ComponentEventContext { 426 | e: Event; 427 | el: Element; 428 | pub(name: string, data: T): void; 429 | query(selector: string): Element | null; 430 | queryAll(selector: string): NodeListOf | null; 431 | } 432 | ``` 433 | 434 | `e` is the native DOM Event. You can call APIs like `.preventDefault()` or 435 | `.stopPropagation()` via this object. 436 | 437 | `el` is the DOM Element, which the event handler is bound to, and the event is 438 | dispatched on. 439 | 440 | You can optionally attach data to the event. The attached data is available via 441 | `.detail` property of `CustomEvent` object. 442 | 443 | `pub(type)` dispatches the event to the remote elements which have `sub:type` 444 | class. This should be used with `sub(type)` calls. For example: 445 | 446 | ```ts 447 | { 448 | const { sub, on } = component("my-component"); 449 | sub("my-event"); 450 | on["my-event"] = () => { 451 | alert("Got my-event"); 452 | }; 453 | } 454 | { 455 | const { on } = component("another-component"); 456 | on.click = ({ pub }) => { 457 | pub("my-event"); 458 | }; 459 | } 460 | ``` 461 | 462 | This call dispatches `new CustomEvent("my-type")` to the elements which have 463 | `sub:my-type` class, like `
`. The event doesn't 464 | bubbles up. 465 | 466 | This method is for communicating with the remote elements which aren't in 467 | parent-child relationship. 468 | 469 | ## `mount(name?: string, el?: Element)` 470 | 471 | This function initializes the elements with the given configuration. `component` 472 | call itself initializes the component of the given class name automatically when 473 | document got ready, but if elements are added after the initial page load, you 474 | need to call this method explicitly to initialize capsule's event handlers. 475 | 476 | ```js 477 | // Initializes the all components in the entire page. 478 | mount(); 479 | 480 | // Initializes only "my-component" components in the entire page. 481 | // You can use this when you only added "my-component" component. 482 | mount("my-compnent"); 483 | 484 | // Initializes the all components only in `myDom` element. 485 | // You can use this when you only added something under `myDom`. 486 | mount(undefined, myDom); 487 | 488 | // Initializes only "my-component" components only in `myDom` element. 489 | // You can use this when you only added "my-component" under `myDom`. 490 | mount("my-component", myDom); 491 | ``` 492 | 493 | ## `unmount(name: string, el: Element)` 494 | 495 | This function unmounts the component of the given name from the element. This 496 | removes the all event listeners of the component and also calls the 497 | `__unmount__` hooks. 498 | 499 | ```js 500 | const { on } = component("my-component"); 501 | 502 | on.__unmount__ = () => { 503 | console.log("unmounting!"); 504 | }; 505 | 506 | unmount("my-component", el); 507 | ``` 508 | 509 | Note: It's ok to just remove the mounted elements without calling `unmount`. 510 | Such removals don't cause a problem in most cases, but if you use `outside` 511 | handlers, you need to call unmount to prevent the leakage of the event handler 512 | because outside handlers are bound to `document` object. 513 | 514 | # How `capsule` works 515 | 516 | This section describes how `capsule` works in a big picture. 517 | 518 | Let's look at the below basic example. 519 | 520 | ```js 521 | const { on } = component("my-component"); 522 | 523 | on.click = () => { 524 | console.log("clicked"); 525 | }; 526 | ``` 527 | 528 | This code is roughly translated into jQuery like the below: 529 | 530 | ```js 531 | $(document).read(() => { 532 | $(".my-component").each(function () { 533 | $this = $(this); 534 | 535 | if (isAlreadyInitialized($this)) { 536 | return; 537 | } 538 | 539 | $this.click(() => { 540 | console.log("clicked"); 541 | }); 542 | }); 543 | }); 544 | ``` 545 | 546 | `capsule` can be seen as a syntax sugar for the above pattern (with a few more 547 | utilities). 548 | 549 | # Prior art 550 | 551 | - [capsid](https://github.com/capsidjs/capsid) 552 | - `capsule` is heavily inspired by `capsid` 553 | 554 | # Projects with similar concepts 555 | 556 | - [Flight](https://flightjs.github.io/) by twitter 557 | - Not under active development 558 | - [eddy.js](https://github.com/WebReflection/eddy) 559 | - Archived 560 | 561 | # History 562 | 563 | - 2022-01-13 v0.5.2 Change `el` typing. #2 564 | - 2022-01-12 v0.5.1 Fix `__mount__` hook execution order 565 | - 2022-01-12 v0.5.0 Add tests, setup CI. 566 | - 2022-01-11 v0.4.0 Add outside handlers. 567 | - 2022-01-11 v0.3.0 Add `unmount`. 568 | - 2022-01-11 v0.2.0 Change delegation syntax. 569 | 570 | # License 571 | 572 | MIT 573 | -------------------------------------------------------------------------------- /capsule-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Group 3 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kt3k/capsule", 3 | "version": "0.6.1", 4 | "exports": { 5 | ".": "./mod.ts" 6 | }, 7 | "compilerOptions": { 8 | "lib": ["deno.ns", "deno.unstable", "dom", "esnext"] 9 | }, 10 | "exclude": ["node", "dist.js", "dist.min.js"], 11 | "tasks": { 12 | "test": "deno test -A", 13 | "cov": "deno test --coverage -A", 14 | "lcov": "deno coverage --lcov cov > lcov.info", 15 | "html_cov": "deno coverage --html", 16 | "dist": "deno run -A jsr:@kt3k/pack mod.ts > dist.js", 17 | "min": "deno run -A npm:terser --compress --mangle --toplevel -o dist.min.js -- dist.js", 18 | "size": "deno run --allow-read https://deno.land/x/gzip_size@v0.3.0/cli.ts --include-original dist.min.js", 19 | "twd": "deno run -A --allow-read=. --allow-write=style.css --allow-net=deno.land,esm.sh,cdn.esm.sh https://deno.land/x/twd@v0.4.8/cli.ts -o style.css index.html", 20 | "twd-w": "deno task twd -- -w", 21 | "start": "deno run --allow-read=. --allow-net=0.0.0.0:8000 deploy.ts" 22 | }, 23 | "imports": { 24 | "@std/assert": "jsr:@std/assert@^0.226.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /deploy.ts: -------------------------------------------------------------------------------- 1 | import { lookup } from "https://deno.land/x/media_types/mod.ts"; 2 | 3 | console.log("Visit http://localhost:8000/"); 4 | Deno.serve(async ({ url }) => { 5 | let path = "." + new URL(url).pathname; 6 | if (path.endsWith("/")) { 7 | path += "index.html"; 8 | } 9 | return new Response(await Deno.readFile(path), { 10 | headers: { "content-type": lookup(path) || "application/octet-stream" }, 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /dist.js: -------------------------------------------------------------------------------- 1 | // util.ts 2 | var READY_STATE_CHANGE = "readystatechange"; 3 | var p; 4 | function documentReady() { 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 | doc.addEventListener(READY_STATE_CHANGE, checkReady); 14 | checkReady(); 15 | }); 16 | } 17 | var boldColor = (color) => `color: ${color}; font-weight: bold;`; 18 | var defaultEventColor = "#f012be"; 19 | function logEvent({ 20 | component: component2, 21 | e, 22 | module, 23 | color 24 | }) { 25 | if (typeof __DEV__ === "boolean" && !__DEV__) 26 | return; 27 | const event = e.type; 28 | console.groupCollapsed( 29 | `${module}> %c${event}%c on %c${component2}`, 30 | boldColor(color || defaultEventColor), 31 | "", 32 | boldColor("#1a80cc") 33 | ); 34 | console.log(e); 35 | if (e.target) { 36 | console.log(e.target); 37 | } 38 | console.groupEnd(); 39 | } 40 | 41 | // mod.ts 42 | var registry = {}; 43 | function assert(assertion, message) { 44 | if (!assertion) { 45 | throw new Error(message); 46 | } 47 | } 48 | function assertComponentNameIsValid(name) { 49 | assert(typeof name === "string", "The name should be a string"); 50 | assert( 51 | !!registry[name], 52 | `The component of the given name is not registered: ${name}` 53 | ); 54 | } 55 | function component(name) { 56 | assert( 57 | typeof name === "string" && !!name, 58 | "Component name must be a non-empty string" 59 | ); 60 | assert( 61 | !registry[name], 62 | `The component of the given name is already registered: ${name}` 63 | ); 64 | const initClass = `${name}-\u{1F48A}`; 65 | const hooks = [({ el }) => { 66 | el.classList.add(name); 67 | el.classList.add(initClass); 68 | el.addEventListener(`__ummount__:${name}`, () => { 69 | el.classList.remove(initClass); 70 | }, { once: true }); 71 | }]; 72 | const mountHooks = []; 73 | const initializer = (el) => { 74 | if (!el.classList.contains(initClass)) { 75 | const e = new CustomEvent("__mount__", { bubbles: false }); 76 | const ctx = createEventContext(e, el); 77 | hooks.map((cb) => { 78 | cb(ctx); 79 | }); 80 | mountHooks.map((cb) => { 81 | cb(ctx); 82 | }); 83 | } 84 | }; 85 | initializer.sel = `.${name}:not(.${initClass})`; 86 | registry[name] = initializer; 87 | documentReady().then(() => { 88 | mount(name); 89 | }); 90 | const on = new Proxy(() => { 91 | }, { 92 | set(_, type, value) { 93 | return addEventBindHook(name, hooks, mountHooks, type, value); 94 | }, 95 | get(_, outside) { 96 | if (outside === "outside") { 97 | return new Proxy({}, { 98 | set(_2, type, value) { 99 | assert( 100 | typeof value === "function", 101 | `Event handler must be a function, ${typeof value} (${value}) is given` 102 | ); 103 | hooks.push(({ el }) => { 104 | const listener = (e) => { 105 | if (el !== e.target && !el.contains(e.target)) { 106 | logEvent({ 107 | module: "outside", 108 | color: "#39cccc", 109 | e, 110 | component: name 111 | }); 112 | value(createEventContext(e, el)); 113 | } 114 | }; 115 | document.addEventListener(type, listener); 116 | el.addEventListener(`__unmount__:${name}`, () => { 117 | document.removeEventListener(type, listener); 118 | }, { once: true }); 119 | }); 120 | return true; 121 | } 122 | }); 123 | } 124 | return null; 125 | }, 126 | apply(_target, _thisArg, args) { 127 | const selector = args[0]; 128 | assert( 129 | typeof selector === "string", 130 | "Delegation selector must be a string. ${typeof selector} is given." 131 | ); 132 | return new Proxy({}, { 133 | set(_, type, value) { 134 | return addEventBindHook( 135 | name, 136 | hooks, 137 | mountHooks, 138 | type, 139 | // deno-lint-ignore no-explicit-any 140 | value, 141 | selector 142 | ); 143 | } 144 | }); 145 | } 146 | }); 147 | const is = (name2) => { 148 | hooks.push(({ el }) => { 149 | el.classList.add(name2); 150 | }); 151 | }; 152 | const sub = (type) => is(`sub:${type}`); 153 | const innerHTML = (html) => { 154 | hooks.push(({ el }) => { 155 | el.innerHTML = html; 156 | }); 157 | }; 158 | return { on, is, sub, innerHTML }; 159 | } 160 | function createEventContext(e, el) { 161 | return { 162 | e, 163 | el, 164 | query: (s) => el.querySelector(s), 165 | queryAll: (s) => el.querySelectorAll(s), 166 | pub: (type, data) => { 167 | document.querySelectorAll(`.sub\\:${type}`).forEach((el2) => { 168 | el2.dispatchEvent( 169 | new CustomEvent(type, { bubbles: false, detail: data }) 170 | ); 171 | }); 172 | } 173 | }; 174 | } 175 | function addEventBindHook(name, hooks, mountHooks, type, handler, selector) { 176 | assert( 177 | typeof handler === "function", 178 | `Event handler must be a function, ${typeof handler} (${handler}) is given` 179 | ); 180 | if (type === "__mount__") { 181 | mountHooks.push(handler); 182 | return true; 183 | } 184 | if (type === "__unmount__") { 185 | hooks.push(({ el }) => { 186 | el.addEventListener(`__unmount__:${name}`, () => { 187 | handler(createEventContext(new CustomEvent("__unmount__"), el)); 188 | }, { once: true }); 189 | }); 190 | return true; 191 | } 192 | hooks.push(({ el }) => { 193 | const listener = (e) => { 194 | if (!selector || [].some.call( 195 | el.querySelectorAll(selector), 196 | (node) => node === e.target || node.contains(e.target) 197 | )) { 198 | logEvent({ 199 | module: "\u{1F48A}", 200 | color: "#e0407b", 201 | e, 202 | component: name 203 | }); 204 | handler(createEventContext(e, el)); 205 | } 206 | }; 207 | el.addEventListener(`__unmount__:${name}`, () => { 208 | el.removeEventListener(type, listener); 209 | }, { once: true }); 210 | el.addEventListener(type, listener); 211 | }); 212 | return true; 213 | } 214 | function mount(name, el) { 215 | let classNames; 216 | if (!name) { 217 | classNames = Object.keys(registry); 218 | } else { 219 | assertComponentNameIsValid(name); 220 | classNames = [name]; 221 | } 222 | classNames.map((className) => { 223 | [].map.call( 224 | (el || document).querySelectorAll(registry[className].sel), 225 | registry[className] 226 | ); 227 | }); 228 | } 229 | function unmount(name, el) { 230 | assert( 231 | !!registry[name], 232 | `The component of the given name is not registered: ${name}` 233 | ); 234 | el.dispatchEvent(new CustomEvent(`__unmount__:${name}`)); 235 | } 236 | export { 237 | component, 238 | mount, 239 | unmount 240 | }; 241 | /*! Capsule v0.6.0 | Copyright 2022 Yoshiya Hinosawa and Capsule contributors | MIT license */ 242 | -------------------------------------------------------------------------------- /dist.min.js: -------------------------------------------------------------------------------- 1 | var e,n="readystatechange";var t=e=>`color: ${e}; font-weight: bold;`,o="#f012be";function s({component:e,e:n,module:s,color:r}){if("boolean"==typeof __DEV__&&!__DEV__)return;const c=n.type;console.groupCollapsed(`${s}> %c${c}%c on %c${e}`,t(r||o),"",t("#1a80cc")),console.log(n),n.target&&console.log(n.target),console.groupEnd()}var r={};function c(e,n){if(!e)throw new Error(n)}function u(t){c("string"==typeof t&&!!t,"Component name must be a non-empty string"),c(!r[t],`The component of the given name is already registered: ${t}`);const o=`${t}-💊`,u=[({el:e})=>{e.classList.add(t),e.classList.add(o),e.addEventListener(`__ummount__:${t}`,(()=>{e.classList.remove(o)}),{once:!0})}],m=[],d=e=>{if(!e.classList.contains(o)){const n=i(new CustomEvent("__mount__",{bubbles:!1}),e);u.map((e=>{e(n)})),m.map((e=>{e(n)}))}};d.sel=`.${t}:not(.${o})`,r[t]=d,(e=e||new Promise((e=>{const t=document,o=()=>{"complete"===t.readyState&&(e(),t.removeEventListener(n,o))};t.addEventListener(n,o),o()}))).then((()=>{a(t)}));const _=new Proxy((()=>{}),{set:(e,n,o)=>l(t,u,m,n,o),get:(e,n)=>"outside"===n?new Proxy({},{set:(e,n,o)=>(c("function"==typeof o,`Event handler must be a function, ${typeof o} (${o}) is given`),u.push((({el:e})=>{const r=n=>{e===n.target||e.contains(n.target)||(s({module:"outside",color:"#39cccc",e:n,component:t}),o(i(n,e)))};document.addEventListener(n,r),e.addEventListener(`__unmount__:${t}`,(()=>{document.removeEventListener(n,r)}),{once:!0})})),!0)}):null,apply(e,n,o){const s=o[0];return c("string"==typeof s,"Delegation selector must be a string. ${typeof selector} is given."),new Proxy({},{set:(e,n,o)=>l(t,u,m,n,o,s)})}}),p=e=>{u.push((({el:n})=>{n.classList.add(e)}))};return{on:_,is:p,sub:e=>p(`sub:${e}`),innerHTML:e=>{u.push((({el:n})=>{n.innerHTML=e}))}}}function i(e,n){return{e:e,el:n,query:e=>n.querySelector(e),queryAll:e=>n.querySelectorAll(e),pub:(e,n)=>{document.querySelectorAll(`.sub\\:${e}`).forEach((t=>{t.dispatchEvent(new CustomEvent(e,{bubbles:!1,detail:n}))}))}}}function l(e,n,t,o,r,u){return c("function"==typeof r,`Event handler must be a function, ${typeof r} (${r}) is given`),"__mount__"===o?(t.push(r),!0):"__unmount__"===o?(n.push((({el:n})=>{n.addEventListener(`__unmount__:${e}`,(()=>{r(i(new CustomEvent("__unmount__"),n))}),{once:!0})})),!0):(n.push((({el:n})=>{const t=t=>{u&&![].some.call(n.querySelectorAll(u),(e=>e===t.target||e.contains(t.target)))||(s({module:"💊",color:"#e0407b",e:t,component:e}),r(i(t,n)))};n.addEventListener(`__unmount__:${e}`,(()=>{n.removeEventListener(o,t)}),{once:!0}),n.addEventListener(o,t)})),!0)}function a(e,n){let t;e?(!function(e){c("string"==typeof e,"The name should be a string"),c(!!r[e],`The component of the given name is not registered: ${e}`)}(e),t=[e]):t=Object.keys(r),t.map((e=>{[].map.call((n||document).querySelectorAll(r[e].sel),r[e])}))}function m(e,n){c(!!r[e],`The component of the given name is not registered: ${e}`),n.dispatchEvent(new CustomEvent(`__unmount__:${e}`))}export{u as component,a as mount,m as unmount}; 2 | /*! Capsule v0.6.0 | Copyright 2022 Yoshiya Hinosawa and Capsule contributors | MIT license */ -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /dom_polyfill_node.ts: -------------------------------------------------------------------------------- 1 | import jsdom from "jsdom"; 2 | const { JSDOM } = jsdom; 3 | const dom = new JSDOM(``); 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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Capsule | Event-driven DOM programming in a new style 7 | 8 | 9 | 10 |
11 | 12 |

13 | 14 | Capsule examples 15 |

16 |
17 |

18 | This page shows the examples of 19 | capsule 20 | frontend library.

21 |
22 |

Mirroring example

23 |

The example of mirroring the input value to another dom.

24 |

JS

25 |
const { on } = component("mirroring");
 26 | 
 27 | on.input = ({ query }) => {
 28 |   query(".dest").textContent = query(".src").value;
 29 | };
30 |

HTML

31 |

 32 |     
 38 |     

Result

39 |
40 |
41 | 42 |
43 | 44 |
45 |

Mount hook example

46 |

When you assign a function to "on.__mount__", it's called when the component is mounted.

47 |

JS

48 |
const { on } = component("hello");
 49 | 
 50 | on.__mount__ = ({ el }) => {
 51 |   el.textContent = `Hello, I'm ${el.textContent}! 👋`;
 52 | };
53 |

HTML

54 |

 55 |     
 58 |     

Result

59 |
60 |
61 | 62 |
63 | 64 |
65 |

Pubsub example

66 |

67 | capsule 68 | supports pubsub pattern with "pub" and "sub" methods. 69 |

70 |

JS

71 |
const EVENT = "my-event";
 72 | 
 73 | {
 74 |   const { on } = component("pub-element");
 75 | 
 76 |   on.click = ({ pub }) => {
 77 |     pub(EVENT, { hello: "clicked!" });
 78 |   };
 79 | }
 80 | 
 81 | {
 82 |   const { on, sub } = component("sub-element");
 83 | 
 84 |   sub(EVENT);
 85 | 
 86 |   on[EVENT] = ({ e, el }) => {
 87 |     el.textContent += " " + e.detail.hello;
 88 |   };
 89 | }
90 | 94 |

HTML

95 |

 96 |     

Result

97 |
98 |
99 | 100 |
101 | 102 |
103 |

Event delegation example

104 |

105 | Capsule supports 106 | Event delegation 107 | pattern. 108 |

109 | 120 |

JS

121 |
const { on } = component("delegation-example");
122 | 
123 | on(".btn").click = ({ query }) => {
124 |   query(".result").textContent += " .btn clicked!";
125 | };
126 |     
127 |

HTML

128 |

129 |     

Result

130 |
131 |
132 | 133 |
134 | 135 |
136 |

Outside event example

137 |

138 | When you're creating floating UI patterns such as tooltips or modal dialogs, you often need to handle the events "outside" of the target dom. 139 | Capsule supports this pattern with "on.outside[eventName]". 140 |

141 | 144 |

JS

145 |
const { on } = component("outside-example");
146 | 
147 | on.outside.click = ({ el }) => {
148 |   el.textContent += " outside clicked!";
149 | }
150 |

HTML

151 |

152 |     

Result

153 |
154 |
155 | 156 |
157 | 158 |
159 |

Prevent default example

160 |

If you need to preventDefault or stopPropagation, you can access it via '.e' prop.

161 | 164 |

JS

165 |
const { on } = component("prevent-example");
166 | 
167 | on.click = ({ e }) => {
168 |   e.preventDefault();
169 |   alert("A link is clicked, but the page doesn't move to the target url because the default behavior is prevented ;)");
170 | };
171 |

HTML

172 |

173 |     

Result

174 |
175 |
176 |

177 | More details about capsule library is available in 178 | the github repository. 179 |

180 |
181 | 185 | 188 | 189 | 194 | 210 | -------------------------------------------------------------------------------- /loader.js: -------------------------------------------------------------------------------- 1 | globalThis.capsuleLoader = import("./dist.min.js"); 2 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | /*! Capsule v0.6.1 | Copyright 2022 Yoshiya Hinosawa and Capsule contributors | MIT license */ 2 | import { documentReady, logEvent } from "./util.ts"; 3 | 4 | interface Initializer { 5 | (el: HTMLElement): void; 6 | /** The elector for the component */ 7 | sel: string; 8 | } 9 | interface RegistryType { 10 | [key: string]: Initializer; 11 | } 12 | interface EventRegistry { 13 | outside: { 14 | [key: string]: EventHandler; 15 | }; 16 | // deno-lint-ignore ban-types 17 | [key: string]: EventHandler | {}; 18 | (selector: string): { 19 | [key: string]: EventHandler; 20 | }; 21 | } 22 | interface ComponentResult { 23 | on: EventRegistry; 24 | is(name: string): void; 25 | sub(type: string): void; 26 | innerHTML(html: string): void; 27 | } 28 | 29 | interface ComponentEventContext { 30 | /** The event */ 31 | e: Event; 32 | /** The element */ 33 | el: HTMLElement; 34 | /** Queries elements by the given selector under the component dom */ 35 | query(selector: string): T | null; 36 | /** Queries all elements by the given selector under the component dom */ 37 | queryAll( 38 | selector: string, 39 | ): NodeListOf; 40 | /** Publishes the event. Events are delivered to elements which have `sub:event` class. 41 | * The dispatched events don't bubbles up */ 42 | pub(name: string, data?: T): void; 43 | } 44 | 45 | type EventHandler = (el: ComponentEventContext) => void; 46 | 47 | /** The registry of component initializers. */ 48 | const registry: RegistryType = {}; 49 | 50 | /** 51 | * Asserts the given condition holds, otherwise throws. 52 | * @param assertion The assertion expression 53 | * @param message The assertion message 54 | */ 55 | function assert(assertion: boolean, message: string): void { 56 | if (!assertion) { 57 | throw new Error(message); 58 | } 59 | } 60 | 61 | /** Asserts the given name is a valid component name. 62 | * @param name The component name */ 63 | function assertComponentNameIsValid(name: unknown): void { 64 | assert(typeof name === "string", "The name should be a string"); 65 | assert( 66 | !!registry[name as string], 67 | `The component of the given name is not registered: ${name}`, 68 | ); 69 | } 70 | 71 | export function component(name: string): ComponentResult { 72 | assert( 73 | typeof name === "string" && !!name, 74 | "Component name must be a non-empty string", 75 | ); 76 | assert( 77 | !registry[name], 78 | `The component of the given name is already registered: ${name}`, 79 | ); 80 | 81 | const initClass = `${name}-💊`; 82 | 83 | // Hooks for mount phase 84 | const hooks: EventHandler[] = [({ el }) => { 85 | // FIXME(kt3k): the below can be written as .add(name, initClass) 86 | // when deno_dom fixes add class. 87 | el.classList.add(name); 88 | el.classList.add(initClass); 89 | el.addEventListener(`__ummount__:${name}`, () => { 90 | el.classList.remove(initClass); 91 | }, { once: true }); 92 | }]; 93 | const mountHooks: EventHandler[] = []; 94 | 95 | /** Initializes the html element by the given configuration. */ 96 | const initializer = (el: HTMLElement) => { 97 | if (!el.classList.contains(initClass)) { 98 | const e = new CustomEvent("__mount__", { bubbles: false }); 99 | const ctx = createEventContext(e, el); 100 | // Initialize `before mount` hooks 101 | // This includes: 102 | // - initialization of event handlers 103 | // - initialization of innerHTML 104 | // - initialization of class names (is, sub) 105 | hooks.map((cb) => { 106 | cb(ctx); 107 | }); 108 | // Execute __mount__ hooks 109 | mountHooks.map((cb) => { 110 | cb(ctx); 111 | }); 112 | } 113 | }; 114 | 115 | // The selector 116 | initializer.sel = `.${name}:not(.${initClass})`; 117 | 118 | registry[name] = initializer; 119 | 120 | documentReady().then(() => { 121 | mount(name); 122 | }); 123 | 124 | // deno-lint-ignore no-explicit-any 125 | const on: any = new Proxy(() => {}, { 126 | set(_: unknown, type: string, value: unknown): boolean { 127 | // deno-lint-ignore no-explicit-any 128 | return addEventBindHook(name, hooks, mountHooks, type, value as any); 129 | }, 130 | get(_: unknown, outside: string) { 131 | if (outside === "outside") { 132 | return new Proxy({}, { 133 | set(_: unknown, type: string, value: unknown): boolean { 134 | assert( 135 | typeof value === "function", 136 | `Event handler must be a function, ${typeof value} (${value}) is given`, 137 | ); 138 | hooks.push(({ el }) => { 139 | const listener = (e: Event) => { 140 | // deno-lint-ignore no-explicit-any 141 | if (el !== e.target && !el.contains(e.target as any)) { 142 | logEvent({ 143 | module: "outside", 144 | color: "#39cccc", 145 | e, 146 | component: name, 147 | }); 148 | (value as EventHandler)(createEventContext(e, el)); 149 | } 150 | }; 151 | document.addEventListener(type, listener); 152 | el.addEventListener(`__unmount__:${name}`, () => { 153 | document.removeEventListener(type, listener); 154 | }, { once: true }); 155 | }); 156 | return true; 157 | }, 158 | }); 159 | } 160 | return null; 161 | }, 162 | apply(_target, _thisArg, args) { 163 | const selector = args[0]; 164 | assert( 165 | typeof selector === "string", 166 | "Delegation selector must be a string. ${typeof selector} is given.", 167 | ); 168 | return new Proxy({}, { 169 | set(_: unknown, type: string, value: unknown): boolean { 170 | return addEventBindHook( 171 | name, 172 | hooks, 173 | mountHooks, 174 | type, 175 | // deno-lint-ignore no-explicit-any 176 | value as any, 177 | selector, 178 | ); 179 | }, 180 | }); 181 | }, 182 | }); 183 | 184 | const is = (name: string) => { 185 | hooks.push(({ el }) => { 186 | el.classList.add(name); 187 | }); 188 | }; 189 | const sub = (type: string) => is(`sub:${type}`); 190 | const innerHTML = (html: string) => { 191 | hooks.push(({ el }) => { 192 | el.innerHTML = html; 193 | }); 194 | }; 195 | 196 | return { on, is, sub, innerHTML }; 197 | } 198 | 199 | function createEventContext(e: Event, el: HTMLElement): ComponentEventContext { 200 | return { 201 | e, 202 | el, 203 | query: (s: string) => el.querySelector(s), 204 | queryAll: (s: string) => el.querySelectorAll(s), 205 | pub: (type: string, data?: unknown) => { 206 | document.querySelectorAll(`.sub\\:${type}`).forEach((el) => { 207 | el.dispatchEvent( 208 | new CustomEvent(type, { bubbles: false, detail: data }), 209 | ); 210 | }); 211 | }, 212 | }; 213 | } 214 | 215 | function addEventBindHook( 216 | name: string, 217 | hooks: EventHandler[], 218 | mountHooks: EventHandler[], 219 | type: string, 220 | handler: (ctx: ComponentEventContext) => void, 221 | selector?: string, 222 | ): boolean { 223 | assert( 224 | typeof handler === "function", 225 | `Event handler must be a function, ${typeof handler} (${handler}) is given`, 226 | ); 227 | if (type === "__mount__") { 228 | mountHooks.push(handler); 229 | return true; 230 | } 231 | if (type === "__unmount__") { 232 | hooks.push(({ el }) => { 233 | el.addEventListener(`__unmount__:${name}`, () => { 234 | handler(createEventContext(new CustomEvent("__unmount__"), el)); 235 | }, { once: true }); 236 | }); 237 | return true; 238 | } 239 | hooks.push(({ el }) => { 240 | const listener = (e: Event) => { 241 | if ( 242 | !selector || 243 | [].some.call( 244 | el.querySelectorAll(selector), 245 | (node: Node) => node === e.target || node.contains(e.target as Node), 246 | ) 247 | ) { 248 | logEvent({ 249 | module: "💊", 250 | color: "#e0407b", 251 | e, 252 | component: name, 253 | }); 254 | handler(createEventContext(e, el)); 255 | } 256 | }; 257 | el.addEventListener(`__unmount__:${name}`, () => { 258 | el.removeEventListener(type, listener); 259 | }, { once: true }); 260 | el.addEventListener(type, listener); 261 | }); 262 | return true; 263 | } 264 | 265 | export function mount(name?: string | null, el?: HTMLElement) { 266 | let classNames: string[]; 267 | 268 | if (!name) { 269 | classNames = Object.keys(registry); 270 | } else { 271 | assertComponentNameIsValid(name); 272 | 273 | classNames = [name]; 274 | } 275 | 276 | classNames.map((className) => { 277 | [].map.call( 278 | (el || document).querySelectorAll(registry[className].sel), 279 | registry[className], 280 | ); 281 | }); 282 | } 283 | 284 | export function unmount(name: string, el: HTMLElement) { 285 | assert( 286 | !!registry[name], 287 | `The component of the given name is not registered: ${name}`, 288 | ); 289 | el.dispatchEvent(new CustomEvent(`__unmount__:${name}`)); 290 | } 291 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;margin:0;padding:0;line-height:inherit;color:inherit}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}table{text-indent:0;border-color:inherit;border-collapse:collapse}hr{height:0;color:inherit;border-top-width:1px}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}button{background-color:transparent;background-image:none}body{font-family:inherit;line-height:inherit}*,::before,::after{box-sizing:border-box;border:0 solid #e5e7eb}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}::-moz-focus-inner{border-style:none;padding:0}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}pre,code,kbd,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}body,blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre,fieldset,ol,ul{margin:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset,ol,ul,legend{padding:0}textarea{resize:vertical}button,[role="button"]{cursor:pointer}:-moz-focusring{outline:1px dotted ButtonText}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}summary{display:list-item}:root{-moz-tab-size:4;tab-size:4}ol,ul{list-style:none}img{border-style:solid}button,select{text-transform:none}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}sub{bottom:-0.25em}sup{top:-0.5em}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}::-webkit-search-decoration{-webkit-appearance:none}*{--tw-shadow:0 0 transparent}.text-gray-700{--tw-text-opacity:1;color:#374151;color:rgba(55,65,81,var(--tw-text-opacity))}.text-blue-800{--tw-text-opacity:1;color:#1e40af;color:rgba(30,64,175,var(--tw-text-opacity))}.text-gray-100{--tw-text-opacity:1;color:#f3f4f6;color:rgba(243,244,246,var(--tw-text-opacity))}.text-purple-800{--tw-text-opacity:1;color:#5b21b6;color:rgba(91,33,182,var(--tw-text-opacity))}.px-2{padding-left:0.5rem;padding-right:0.5rem}.text-2xl{font-size:1.5rem;line-height:2rem}.bg-blue-50{--tw-bg-opacity:1;background-color:#eff6ff;background-color:rgba(239,246,255,var(--tw-bg-opacity))}.px-1{padding-left:0.25rem;padding-right:0.25rem}.py-0{padding-bottom:0px;padding-top:0px}.text-lg{font-size:1.125rem;line-height:1.75rem}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);box-shadow:0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);box-shadow:var(--tw-ring-offset-shadow,0 0 transparent),var(--tw-ring-shadow,0 0 transparent),var(--tw-shadow)}.bg-gray-800{--tw-bg-opacity:1;background-color:#1f2937;background-color:rgba(31,41,55,var(--tw-bg-opacity))}.-mx-2{margin-left:calc(0.5rem * -1);margin-right:calc(0.5rem * -1)}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.bg-purple-50{--tw-bg-opacity:1;background-color:#f5f3ff;background-color:rgba(245,243,255,var(--tw-bg-opacity))}.my-3{margin-bottom:0.75rem;margin-top:0.75rem}.py-10{padding-bottom:2.5rem;padding-top:2.5rem}.m-auto{margin:auto}.flex{display:flex}.gap-2{grid-gap:0.5rem;gap:0.5rem}.w-7{width:1.75rem}.h-7{height:1.75rem}.p-5{padding:1.25rem}.p-2{padding:0.5rem}.m-2{margin:0.5rem}.max-w-screen-lg{max-width:1024px}.font-semibold{font-weight:600}.mt-20{margin-top:5rem}.items-center{align-items:center}.mt-4{margin-top:1rem}.underline{-webkit-text-decoration:underline;text-decoration:underline}.font-medium{font-weight:500}.border{border-width:1px}.mt-10{margin-top:2.5rem}.mt-1{margin-top:0.25rem}.overflow-x-scroll{overflow-x:scroll}.mt-5{margin-top:1.25rem}.justify-center{justify-content:center}.rounded{border-radius:0.25rem}.rounded-lg{border-radius:0.5rem}@media (min-width:1024px){.lg\:mx-2{margin-left:0.5rem;margin-right:0.5rem}}@media (min-width:1024px){.lg\:rounded-lg{border-radius:0.5rem}} -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Yoshiya Hinosawa. All rights reserved. MIT license. 2 | 3 | import { assert, assertEquals, assertThrows } from "@std/assert"; 4 | import "./dom_polyfill_deno.ts"; 5 | import { component, mount, unmount } from "./mod.ts"; 6 | 7 | // disable debug logs because it's too verbose for unit testing 8 | // deno-lint-ignore no-explicit-any 9 | (globalThis as any).__DEV__ = false; 10 | 11 | Deno.test("on.__mount__ is called when the component is mounted", () => { 12 | const name = randomName(); 13 | const { on } = component(name); 14 | 15 | document.body.innerHTML = `
`; 16 | 17 | let called = false; 18 | 19 | on.__mount__ = () => { 20 | called = true; 21 | }; 22 | 23 | mount(); 24 | 25 | assert(called); 26 | }); 27 | 28 | Deno.test("on.__mount__ is called after other initialization is finished", () => { 29 | const name = randomName(); 30 | const { on, is, sub, innerHTML } = component(name); 31 | 32 | document.body.innerHTML = `
`; 33 | 34 | let hasFoo = false; 35 | let hasSubBar = false; 36 | let hasInnerHTML = false; 37 | 38 | on.__mount__ = ({ el }) => { 39 | hasFoo = el.classList.contains("foo"); 40 | hasSubBar = el.classList.contains("sub:bar"); 41 | hasInnerHTML = el.innerHTML === "

hello

"; 42 | }; 43 | 44 | is("foo"); 45 | sub("bar"); 46 | innerHTML("

hello

"); 47 | 48 | mount(); 49 | 50 | assert(hasFoo); 51 | assert(hasSubBar); 52 | assert(hasInnerHTML); 53 | }); 54 | 55 | Deno.test("on.__unmount__ is called when the componet is unmounted", () => { 56 | const name = randomName(); 57 | const { on } = component(name); 58 | 59 | document.body.innerHTML = `
`; 60 | 61 | let called = false; 62 | 63 | on.__unmount__ = () => { 64 | called = true; 65 | }; 66 | 67 | mount(); 68 | assert(!called); 69 | unmount(name, query(`.${name}`)!); 70 | assert(called); 71 | }); 72 | 73 | Deno.test("unmount removes the event listeners", () => { 74 | const name = randomName(); 75 | const { on } = component(name); 76 | 77 | document.body.innerHTML = `
`; 78 | const el = queryByClass(name); 79 | 80 | let count = 0; 81 | on["my-event"] = () => { 82 | count++; 83 | }; 84 | mount(); 85 | assertEquals(count, 0); 86 | el?.dispatchEvent(new CustomEvent("my-event")); 87 | assertEquals(count, 1); 88 | el?.dispatchEvent(new CustomEvent("my-event")); 89 | assertEquals(count, 2); 90 | unmount(name, el!); 91 | el?.dispatchEvent(new CustomEvent("my-event")); 92 | assertEquals(count, 2); 93 | }); 94 | 95 | Deno.test("on[event] is called when the event is dispatched", () => { 96 | const name = randomName(); 97 | const { on } = component(name); 98 | 99 | document.body.innerHTML = `
`; 100 | 101 | let called = false; 102 | 103 | on.click = () => { 104 | called = true; 105 | }; 106 | 107 | mount(); 108 | 109 | query("div")?.dispatchEvent(new Event("click")); 110 | assert(called); 111 | }); 112 | 113 | Deno.test("on(selector)[event] is called when the event is dispatched only under the selector", async () => { 114 | const name = randomName(); 115 | const { on } = component(name); 116 | 117 | document.body.innerHTML = 118 | `
`; 119 | 120 | let onBtn1ClickCalled = false; 121 | let onBtn2ClickCalled = false; 122 | 123 | on(".btn1").click = () => { 124 | onBtn1ClickCalled = true; 125 | }; 126 | 127 | on(".btn2").click = () => { 128 | onBtn2ClickCalled = true; 129 | }; 130 | 131 | mount(); 132 | 133 | const btn = queryByClass("btn1"); 134 | // FIXME(kt3k): workaround for deno_dom & deno issue 135 | // deno_dom doesn't bubble event when the direct target dom doesn't have event handler 136 | btn?.addEventListener("click", () => {}); 137 | btn?.dispatchEvent(new Event("click", { bubbles: true })); 138 | await new Promise((r) => setTimeout(r, 100)); 139 | 140 | assert(onBtn1ClickCalled); 141 | assert(!onBtn2ClickCalled); 142 | }); 143 | 144 | Deno.test("on.outside.event works", () => { 145 | const name = randomName(); 146 | const { on } = component(name); 147 | 148 | document.body.innerHTML = 149 | `
`; 150 | 151 | let calledCount = 0; 152 | 153 | on.outside.click = () => { 154 | calledCount++; 155 | }; 156 | 157 | mount(); 158 | assertEquals(calledCount, 0); 159 | 160 | const sibling = queryByClass("sibling")!; 161 | // FIXME(kt3k): workaround for deno_dom & deno issue 162 | // deno_dom doesn't bubble event when the direct target dom doesn't have event handler 163 | sibling.addEventListener("click", () => {}); 164 | sibling.dispatchEvent(new Event("click", { bubbles: true })); 165 | assertEquals(calledCount, 1); 166 | const root = queryByClass("root")!; 167 | // FIXME(kt3k): workaround for deno_dom & deno issue 168 | // deno_dom doesn't bubble event when the direct target dom doesn't have event handler 169 | root.addEventListener("click", () => {}); 170 | root.dispatchEvent(new Event("click", { bubbles: true })); 171 | assertEquals(calledCount, 2); 172 | }); 173 | 174 | Deno.test("`is` works", () => { 175 | const name = randomName(); 176 | const { is } = component(name); 177 | document.body.innerHTML = `
`; 178 | is("foo"); 179 | mount(); 180 | assert(queryByClass(name)?.classList.contains("foo")); 181 | }); 182 | Deno.test("innerHTML works", () => { 183 | const name = randomName(); 184 | const { innerHTML } = component(name); 185 | document.body.innerHTML = `
`; 186 | innerHTML("

hello

"); 187 | mount(); 188 | assertEquals(queryByClass(name)?.innerHTML, "

hello

"); 189 | }); 190 | Deno.test("pub, sub works", () => { 191 | const EVENT = "my-event"; 192 | const name1 = randomName(); 193 | const name2 = randomName(); 194 | let subCalled = false; 195 | document.body.innerHTML = ` 196 |
197 |
198 | `; 199 | { 200 | const { on, sub } = component(name1); 201 | sub(EVENT); 202 | on[EVENT] = () => { 203 | subCalled = true; 204 | }; 205 | } 206 | { 207 | const { on } = component(name2); 208 | on.__mount__ = ({ pub }) => { 209 | pub(EVENT); 210 | }; 211 | } 212 | assert(!subCalled); 213 | mount(); 214 | assert(subCalled); 215 | }); 216 | 217 | Deno.test("query, queryAll works", () => { 218 | const name = randomName(); 219 | document.body.innerHTML = ` 220 |
221 |

foo

222 |

bar

223 |

baz

224 |
225 | `; 226 | const { on } = component(name); 227 | on.__mount__ = ({ query, queryAll }) => { 228 | assert(query("p") !== null); 229 | assertEquals(query("p")?.textContent, "foo"); 230 | 231 | assertEquals(queryAll("p")[0].textContent, "foo"); 232 | assertEquals(queryAll("p")[1].textContent, "bar"); 233 | assertEquals(queryAll("p")[2].textContent, "baz"); 234 | }; 235 | }); 236 | Deno.test("assign wrong type to on.event, on.outside.event, on(selector).event", () => { 237 | const { on } = component(randomName()); 238 | assertThrows(() => { 239 | on.click = ""; 240 | }); 241 | assertThrows(() => { 242 | on.click = 1; 243 | }); 244 | assertThrows(() => { 245 | on.click = Symbol(); 246 | }); 247 | assertThrows(() => { 248 | on.click = {}; 249 | }); 250 | assertThrows(() => { 251 | on.click = []; 252 | }); 253 | assertThrows(() => { 254 | // deno-lint-ignore no-explicit-any 255 | on(".btn").click = "" as any; 256 | }); 257 | assertThrows(() => { 258 | // deno-lint-ignore no-explicit-any 259 | on(".btn").click = 1 as any; 260 | }); 261 | assertThrows(() => { 262 | // deno-lint-ignore no-explicit-any 263 | on(".btn").click = Symbol() as any; 264 | }); 265 | assertThrows(() => { 266 | // deno-lint-ignore no-explicit-any 267 | on(".btn").click = {} as any; 268 | }); 269 | assertThrows(() => { 270 | // deno-lint-ignore no-explicit-any 271 | on(".btn").click = [] as any; 272 | }); 273 | assertThrows(() => { 274 | // deno-lint-ignore no-explicit-any 275 | on.outside.click = "" as any; 276 | }); 277 | assertThrows(() => { 278 | // deno-lint-ignore no-explicit-any 279 | on.outside.click = 1 as any; 280 | }); 281 | assertThrows(() => { 282 | // deno-lint-ignore no-explicit-any 283 | on.outside.click = Symbol() as any; 284 | }); 285 | assertThrows(() => { 286 | // deno-lint-ignore no-explicit-any 287 | on.outside.click = {} as any; 288 | }); 289 | assertThrows(() => { 290 | // deno-lint-ignore no-explicit-any 291 | on.outside.click = [] as any; 292 | }); 293 | }); 294 | Deno.test("wrong type selector throws with on(selector).event", () => { 295 | const { on } = component(randomName()); 296 | assertThrows(() => { 297 | // deno-lint-ignore no-explicit-any 298 | on(1 as any); 299 | }); 300 | assertThrows(() => { 301 | // deno-lint-ignore no-explicit-any 302 | on(1n as any); 303 | }); 304 | assertThrows(() => { 305 | // deno-lint-ignore no-explicit-any 306 | on({} as any); 307 | }); 308 | assertThrows(() => { 309 | // deno-lint-ignore no-explicit-any 310 | on([] as any); 311 | }); 312 | assertThrows(() => { 313 | // deno-lint-ignore no-explicit-any 314 | on((() => {}) as any); 315 | }); 316 | }); 317 | Deno.test("component throws with non string input", () => { 318 | assertThrows(() => { 319 | // deno-lint-ignore no-explicit-any 320 | component(1 as any); 321 | }); 322 | assertThrows(() => { 323 | // deno-lint-ignore no-explicit-any 324 | component(1n as any); 325 | }); 326 | assertThrows(() => { 327 | // deno-lint-ignore no-explicit-any 328 | component(Symbol() as any); 329 | }); 330 | assertThrows(() => { 331 | // empty name throws 332 | component(""); 333 | }); 334 | assertThrows(() => { 335 | // deno-lint-ignore no-explicit-any 336 | component((() => {}) as any); 337 | }); 338 | assertThrows(() => { 339 | // deno-lint-ignore no-explicit-any 340 | component({} as any); 341 | }); 342 | assertThrows(() => { 343 | // deno-lint-ignore no-explicit-any 344 | component([] as any); 345 | }); 346 | }); 347 | Deno.test("component throws with already registered name", () => { 348 | const name = randomName(); 349 | component(name); 350 | assertThrows(() => { 351 | component(name); 352 | }); 353 | }); 354 | 355 | Deno.test("unmount with non registered name throws", () => { 356 | assertThrows(() => { 357 | unmount(randomName(), document.body); 358 | }); 359 | }); 360 | 361 | // test utils 362 | const randomName = () => "c-" + Math.random().toString(36).slice(2); 363 | const query = (s: string) => document.querySelector(s); 364 | const queryByClass = (name: string) => 365 | document.querySelector(`.${name}`); 366 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Yoshiya Hinosawa. All rights reserved. MIT license. 2 | 3 | const READY_STATE_CHANGE = "readystatechange"; 4 | 5 | let p: Promise; 6 | export function documentReady() { 7 | return p = p || new Promise((resolve) => { 8 | const doc = document; 9 | const checkReady = () => { 10 | if (doc.readyState === "complete") { 11 | resolve(); 12 | doc.removeEventListener(READY_STATE_CHANGE, checkReady); 13 | } 14 | }; 15 | 16 | doc.addEventListener(READY_STATE_CHANGE, checkReady); 17 | 18 | checkReady(); 19 | }); 20 | } 21 | 22 | interface LogEventMessage { 23 | component: string; 24 | e: Event; 25 | module: string; 26 | color?: string; 27 | } 28 | 29 | /** Gets the bold colored style */ 30 | const boldColor = (color: string): string => 31 | `color: ${color}; font-weight: bold;`; 32 | 33 | const defaultEventColor = "#f012be"; 34 | 35 | declare const __DEV__: boolean; 36 | 37 | export function logEvent({ 38 | component, 39 | e, 40 | module, 41 | color, 42 | }: LogEventMessage) { 43 | if (typeof __DEV__ === "boolean" && !__DEV__) return; 44 | const event = e.type; 45 | 46 | console.groupCollapsed( 47 | `${module}> %c${event}%c on %c${component}`, 48 | boldColor(color || defaultEventColor), 49 | "", 50 | boldColor("#1a80cc"), 51 | ); 52 | console.log(e); 53 | 54 | if (e.target) { 55 | console.log(e.target); 56 | } 57 | 58 | console.groupEnd(); 59 | } 60 | --------------------------------------------------------------------------------