├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── assets └── Noctua_Logo.webp ├── integration ├── counter │ ├── browser-sync.cjs │ ├── index.html │ ├── package.json │ ├── src │ │ └── counter.ts │ └── tsconfig.json ├── hacker-news │ ├── browser-sync.cjs │ ├── favicon.svg │ ├── index.html │ ├── package.json │ ├── public │ │ └── images │ │ │ └── y18.svg │ ├── src │ │ ├── components │ │ │ ├── header-link.ts │ │ │ ├── header.ts │ │ │ ├── loading.ts │ │ │ ├── news-card.ts │ │ │ └── news-feed.ts │ │ └── services │ │ │ ├── hn.service.test.ts │ │ │ ├── hn.service.ts │ │ │ └── http.service.ts │ ├── static.html │ ├── tsconfig.json │ ├── vanilla.html │ └── wtr.config.mjs ├── templating │ ├── browser-sync.js │ ├── index.html │ └── package.json └── todo │ ├── browser-sync.cjs │ ├── index.html │ ├── package.json │ ├── src │ ├── ctx │ │ └── storage.ctx.ts │ ├── elements │ │ ├── todo-card.element.ts │ │ ├── todo-form.element.ts │ │ ├── todo-list-footer.element.ts │ │ ├── todo-list.element.ts │ │ └── todo-logger.element.ts │ └── services │ │ ├── storage.service.ts │ │ └── todo.service.ts │ └── tsconfig.json ├── package-lock.json ├── package.json ├── packages ├── di │ ├── LICENSE │ ├── NEW_README.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── lib.ts │ │ └── lib │ │ │ ├── context │ │ │ ├── injector.ts │ │ │ └── protocol.ts │ │ │ ├── dom │ │ │ ├── dom-injector.test.ts │ │ │ ├── dom-injector.ts │ │ │ ├── injectable-el.test.ts │ │ │ └── injectable-el.ts │ │ │ ├── inject.test.ts │ │ │ ├── inject.ts │ │ │ ├── injectable.test.ts │ │ │ ├── injectable.ts │ │ │ ├── injector.test.ts │ │ │ ├── injector.ts │ │ │ ├── lifecycle.test.ts │ │ │ ├── lifecycle.ts │ │ │ ├── metadata.ts │ │ │ └── provider.ts │ ├── tsconfig.json │ ├── typings.d.ts │ └── wtr.config.mjs ├── element │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── lib.ts │ │ └── lib │ │ │ ├── attr-changed.test.ts │ │ │ ├── attr-changed.ts │ │ │ ├── attr.test.ts │ │ │ ├── attr.ts │ │ │ ├── element.test.ts │ │ │ ├── element.ts │ │ │ ├── lifecycle.test.ts │ │ │ ├── lifecycle.ts │ │ │ ├── listen.test.ts │ │ │ ├── listen.ts │ │ │ ├── metadata.ts │ │ │ ├── query-all.test.ts │ │ │ ├── query-all.ts │ │ │ ├── query.test.ts │ │ │ ├── query.ts │ │ │ ├── result.ts │ │ │ ├── tags.test.ts │ │ │ └── tags.ts │ ├── tsconfig.json │ ├── typings.d.ts │ └── wtr.config.mjs ├── observable │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── lib.ts │ │ └── lib │ │ │ ├── metadata.test.ts │ │ │ ├── metadata.ts │ │ │ ├── observe.test.ts │ │ │ └── observe.ts │ ├── tsconfig.json │ └── wtr.config.mjs ├── plugin-vite │ ├── package.json │ ├── src │ │ └── lib.ts │ └── tsconfig.json ├── ssr │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── lib.ts │ │ ├── lib │ │ │ ├── applicator.test.ts │ │ │ ├── applicator.ts │ │ │ ├── template-cache.test.ts │ │ │ ├── template-cache.ts │ │ │ ├── template-loader.test.ts │ │ │ └── template-loader.ts │ │ └── testing │ │ │ └── elements │ │ │ └── my-element │ │ │ ├── my-element.css │ │ │ ├── my-element.html │ │ │ └── my-element.ts │ └── tsconfig.json └── templating │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ ├── lib.ts │ └── lib │ │ ├── bind.test.ts │ │ ├── bind.ts │ │ ├── define.ts │ │ ├── elements │ │ ├── async.element.test.ts │ │ ├── async.element.ts │ │ ├── bind.element.test.ts │ │ ├── bind.element.ts │ │ ├── for.element.test.ts │ │ ├── for.element.ts │ │ ├── if.element.test.ts │ │ ├── if.element.ts │ │ ├── scope.ts │ │ ├── value.element.test.ts │ │ └── value.element.ts │ │ ├── events.ts │ │ ├── expression.test.ts │ │ └── expression.ts │ ├── tsconfig.json │ └── wtr.config.mjs ├── publish_minor.sh ├── publish_next.sh ├── publish_patch.sh ├── publish_rc.sh ├── renovate.json └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main, 4.0] 6 | push: 7 | branches: [main, 4.0] 8 | paths-ignore: 9 | - 'README.md' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | 18 | - name: Bootstrap 19 | run: npm ci 20 | 21 | # - name: Install Playwright Browsers 22 | # run: npx playwright install --with-deps 23 | 24 | - name: Build and Test Packages 25 | run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | **/.nyc_output 5 | **/.cache 6 | **/*.d.ts 7 | !**/vite-env.d.ts 8 | target 9 | .wireit 10 | tsconfig.tsbuildinfo 11 | coverage 12 | 13 | # dependencies 14 | **/node_modules/ 15 | **/dist/ 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | 33 | # misc 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | npm-debug.log 38 | yarn-error.log 39 | testem.log 40 | *.map 41 | lerna-debug.log 42 | firebase-debug.log 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | /test-results/ 48 | /playwright-report/ 49 | /playwright/.cache/ 50 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "editor.codeActionsOnSave": { 4 | "quickfix.prettier": "explicit", 5 | "source.organizeImports.prettier": "explicit" 6 | }, 7 | "[javascript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[html]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[json]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescript]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Joist 3 | 4 | Web components are awesome! Joist is a set of small libraries designed to add the bare minimum to web components to make you as productive as possible. The entire project started years ago when I made my first attempt at bringing dependency injection (@joist/di) to Custom Elements as a way to share state between them. Along the way, several other packages were added to solve different challenges. 5 | 6 | When you have to integrate with many different applications, many different frameworks with many different technologies you need a toolkit to help. 7 | From SalesForce to ServiceNow to React you need to write JavaScript/TypeScript and you need tools to help. 8 | 9 | This toolkit is here to help provide just the functionality you need and nothing more. Use with Lit, FAST, Vanilla WC, Node, wherever you find yourself. 10 | 11 | ## Packages 12 | 13 | | Package | Description | 14 | | ---------------------------------------- | --------------------------------------------- | 15 | | [@joist/di](packages/di) | Small and Efficient dependency Injection | 16 | | [@joist/element](packages/element) | utilities for custom elements | 17 | | [@joist/observable](packages/observable) | Observe changes to class properties | 18 | | [@joist/templating](packages/templating) | Use custom elements to display dynamic values | 19 | 20 | **Sponsored by:** 21 | 22 | [![Noctua Logo](assets/Noctua_Logo.webp)](https://github.com/Noctua-Technology) 23 | -------------------------------------------------------------------------------- /assets/Noctua_Logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joist-framework/joist/5ae51caa97a1278ad2498c188c46755af6a11eaf/assets/Noctua_Logo.webp -------------------------------------------------------------------------------- /integration/counter/browser-sync.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ["*.html", "target/**", "../../packages/**/target/**"], 3 | server: { 4 | baseDir: "./", 5 | routes: { 6 | "/node_modules": "../../node_modules", 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /integration/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Counter 11 | 12 | 27 | 28 | 29 | 30 | 39 | 40 | 41 | 42 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /integration/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@integration/counter", 3 | "version": "3.0.0", 4 | "private": true, 5 | "description": "", 6 | "keywords": [], 7 | "author": "deebloo", 8 | "license": "MIT", 9 | "type": "module", 10 | "scripts": { 11 | "start": "wireit", 12 | "build": "wireit" 13 | }, 14 | "wireit": { 15 | "start": { 16 | "command": "browser-sync start --config=browser-sync.cjs", 17 | "service": true, 18 | "dependencies": [ 19 | { 20 | "script": "build", 21 | "cascade": false 22 | } 23 | ] 24 | }, 25 | "build": { 26 | "command": "tsc --build --pretty", 27 | "clean": "if-file-deleted", 28 | "files": [ 29 | "src/**", 30 | "tsconfig.json", 31 | "../../tsconfig.json" 32 | ], 33 | "output": [ 34 | "target/**", 35 | "tsconfig.tsbuildinfo" 36 | ], 37 | "dependencies": [ 38 | "../../packages/element:build", 39 | "../../packages/di:build", 40 | "../../packages/observable:build", 41 | "../../packages/templating:build" 42 | ] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /integration/counter/src/counter.ts: -------------------------------------------------------------------------------- 1 | import { css, element, html, listen } from "@joist/element"; 2 | import { bind } from "@joist/templating"; 3 | 4 | @element({ 5 | tagName: "joist-counter", 6 | shadowDom: [ 7 | css` 8 | * { 9 | font-size: 200%; 10 | } 11 | 12 | :host { 13 | display: block; 14 | } 15 | 16 | j-val { 17 | width: 4rem; 18 | display: inline-block; 19 | text-align: center; 20 | } 21 | 22 | button { 23 | width: 4rem; 24 | height: 4rem; 25 | border: none; 26 | border-radius: 10px; 27 | background-color: seagreen; 28 | color: white; 29 | cursor: pointer; 30 | } 31 | `, 32 | html` 33 | 34 | 35 | 36 | `, 37 | ], 38 | }) 39 | export class CounterElement extends HTMLElement { 40 | @bind() 41 | accessor count = { 42 | value: 0, 43 | }; 44 | 45 | @listen("click", "#inc") 46 | onIncrement() { 47 | this.count = { 48 | value: this.count.value + 1, 49 | }; 50 | } 51 | 52 | @listen("click", "#dec") 53 | onDecrement() { 54 | this.count = { 55 | value: this.count.value - 1, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /integration/counter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "target" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /integration/hacker-news/browser-sync.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ["*.html", "target/**", "../../packages/**/target/**"], 3 | server: { 4 | baseDir: "./", 5 | routes: { 6 | "/node_modules": "../../node_modules", 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /integration/hacker-news/favicon.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joist-framework/joist/5ae51caa97a1278ad2498c188c46755af6a11eaf/integration/hacker-news/favicon.svg -------------------------------------------------------------------------------- /integration/hacker-news/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 28 | 29 | Hacker News 30 | 31 | 32 | 33 | 56 | 57 | 58 | new 59 | past 60 | comments 61 | ask 62 | show 63 | jobs 64 | submit 65 | 66 | 67 | 68 | 69 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /integration/hacker-news/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@integration/hacker-news", 3 | "version": "3.0.0", 4 | "private": true, 5 | "description": "", 6 | "keywords": [], 7 | "author": "deebloo", 8 | "license": "MIT", 9 | "type": "module", 10 | "scripts": { 11 | "start": "wireit", 12 | "build": "wireit", 13 | "test": "wireit" 14 | }, 15 | "wireit": { 16 | "start": { 17 | "command": "browser-sync start --config=browser-sync.cjs", 18 | "service": true, 19 | "dependencies": [ 20 | { 21 | "script": "build", 22 | "cascade": false 23 | } 24 | ] 25 | }, 26 | "test": { 27 | "command": "wtr --config wtr.config.mjs", 28 | "files": [ 29 | "wtr.config.mjs", 30 | "target/**" 31 | ], 32 | "output": [], 33 | "dependencies": [ 34 | "build" 35 | ] 36 | }, 37 | "build": { 38 | "command": "tsc --build --pretty", 39 | "clean": "if-file-deleted", 40 | "files": [ 41 | "src/**", 42 | "tsconfig.json", 43 | "../../tsconfig.json" 44 | ], 45 | "output": [ 46 | "target/**", 47 | "tsconfig.tsbuildinfo" 48 | ], 49 | "dependencies": [ 50 | "../../packages/element:build", 51 | "../../packages/di:build", 52 | "../../packages/observable:build", 53 | "../../packages/templating:build" 54 | ] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /integration/hacker-news/public/images/y18.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /integration/hacker-news/src/components/header-link.ts: -------------------------------------------------------------------------------- 1 | import { attr, css, element, html } from "@joist/element"; 2 | import { bind } from "@joist/templating"; 3 | 4 | @element({ 5 | tagName: "hn-header-link", 6 | shadowDom: [ 7 | css` 8 | :host { 9 | display: inline-flex; 10 | align-items: center; 11 | padding: 0.5rem; 12 | } 13 | 14 | a { 15 | color: #000; 16 | font-size: 10pt; 17 | text-decoration: none; 18 | } 19 | 20 | a:visited { 21 | color: #000; 22 | } 23 | 24 | a:hover { 25 | text-decoration: underline; 26 | } 27 | `, 28 | html` 29 | 30 | 31 | 32 | 33 | 34 | `, 35 | ], 36 | }) 37 | export class HnHeader extends HTMLElement { 38 | @attr() 39 | @bind() 40 | accessor href = "#"; 41 | } 42 | -------------------------------------------------------------------------------- /integration/hacker-news/src/components/header.ts: -------------------------------------------------------------------------------- 1 | import { attr, css, element, html } from "@joist/element"; 2 | import { bind } from "@joist/templating"; 3 | 4 | @element({ 5 | tagName: "hn-header", 6 | shadowDom: [ 7 | css` 8 | :host { 9 | background: rgb(255, 102, 0); 10 | display: flex; 11 | align-items: center; 12 | flex-wrap: wrap; 13 | } 14 | 15 | h1 { 16 | font-size: 1rem; 17 | margin: 0; 18 | padding: 0.5rem; 19 | } 20 | 21 | nav { 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | img { 27 | border: solid 1px #ffffff; 28 | margin: 0.5rem; 29 | } 30 | `, 31 | html` 32 | 33 | 34 | 35 | 36 |

Hacker News

37 | 38 | 41 | `, 42 | ], 43 | }) 44 | export class HnHeader extends HTMLElement { 45 | @attr() 46 | accessor role = "banner"; 47 | 48 | @attr() 49 | @bind() 50 | accessor img = "/public/images/y18.svg"; 51 | } 52 | -------------------------------------------------------------------------------- /integration/hacker-news/src/components/loading.ts: -------------------------------------------------------------------------------- 1 | import { css, element } from "@joist/element"; 2 | 3 | @element({ 4 | tagName: "hn-loading", 5 | shadowDom: [ 6 | css` 7 | :host { 8 | border: 16px solid #f3f3f3; /* Light grey */ 9 | border-top: 16px solid #3498db; /* Blue */ 10 | border-radius: 50%; 11 | width: 50px; 12 | height: 50px; 13 | animation: spin 2s linear infinite; 14 | display: block; 15 | } 16 | 17 | @keyframes spin { 18 | 0% { 19 | transform: rotate(0deg); 20 | } 21 | 100% { 22 | transform: rotate(360deg); 23 | } 24 | } 25 | `, 26 | ], 27 | }) 28 | export class HnLoadingElement extends HTMLElement {} 29 | -------------------------------------------------------------------------------- /integration/hacker-news/src/components/news-card.ts: -------------------------------------------------------------------------------- 1 | import { attr, css, element, html } from "@joist/element"; 2 | import { bind } from "@joist/templating"; 3 | 4 | @element({ 5 | tagName: "hn-news-card", 6 | shadowDom: [ 7 | css` 8 | :host { 9 | padding: 1rem; 10 | display: flex; 11 | gap: 0.5rem; 12 | } 13 | 14 | a { 15 | color: #000; 16 | text-decoration: none; 17 | } 18 | 19 | a:visited { 20 | text-decoration: none; 21 | } 22 | 23 | #title { 24 | font-size: 1.1em; 25 | } 26 | 27 | .details { 28 | color: #716f6f; 29 | display: flex; 30 | gap: 1rem; 31 | } 32 | `, 33 | html` 34 |
35 | 36 |
37 | 38 |
39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 |
54 | 55 |
56 |
57 | 58 | points 59 |
60 | 61 |
62 | by 63 | 64 |
65 | 66 |
67 | 68 | comments 69 |
70 |
71 |
72 | `, 73 | ], 74 | }) 75 | export class HnNewsCard extends HTMLElement { 76 | @attr() 77 | @bind() 78 | accessor number = 1; 79 | 80 | @attr() 81 | @bind() 82 | accessor comments = 0; 83 | 84 | @attr() 85 | @bind() 86 | accessor points = 0; 87 | 88 | @attr() 89 | @bind() 90 | accessor href = ""; 91 | 92 | @attr() 93 | @bind() 94 | accessor author = ""; 95 | 96 | @bind({ 97 | compute(i) { 98 | try { 99 | return new URL(i.href).hostname; 100 | } catch (e) { 101 | return ""; 102 | } 103 | }, 104 | }) 105 | accessor host = ""; 106 | } 107 | -------------------------------------------------------------------------------- /integration/hacker-news/src/components/news-feed.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, injected } from "@joist/di"; 2 | import { css, element, html } from "@joist/element"; 3 | import { bind } from "@joist/templating"; 4 | 5 | import { type HnItem, HnService } from "../services/hn.service.js"; 6 | 7 | @injectable() 8 | @element({ 9 | tagName: "hn-news-feed", 10 | shadowDom: [ 11 | css` 12 | :host { 13 | display: contents; 14 | } 15 | 16 | .loading-container { 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | padding: 4rem; 21 | } 22 | `, 23 | html` 24 | 25 | 30 | 31 | 32 | 33 | 48 | 49 | `, 50 | ], 51 | }) 52 | export class HnNewsFeed extends HTMLElement { 53 | #hn = inject(HnService); 54 | 55 | @bind() 56 | accessor stories: HnItem[] = []; 57 | 58 | @bind() 59 | accessor isLoading = true; 60 | 61 | @injected() 62 | async onInjected() { 63 | const hn = this.#hn(); 64 | 65 | this.stories = await hn.getTopStories(); 66 | this.isLoading = false; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /integration/hacker-news/src/services/hn.service.test.ts: -------------------------------------------------------------------------------- 1 | import { Injector } from "@joist/di"; 2 | import { assert } from "chai"; 3 | 4 | import { HTTP, HnService } from "./hn.service.js"; 5 | import { HttpService } from "./http.service.js"; 6 | 7 | it("should run", async () => { 8 | const testbed = new Injector({ 9 | providers: [ 10 | [ 11 | HTTP, 12 | { 13 | use: class extends HttpService { 14 | id = 0; 15 | 16 | async fetch(input: URL, _init?: RequestInit): Promise { 17 | const url = new URL(input); 18 | 19 | if (url.pathname === "/v0/beststories.json") { 20 | return Response.json([0, 1, 2, 3, 4]); 21 | } 22 | 23 | if (url.pathname.startsWith("/v0/item/")) { 24 | this.id++; 25 | 26 | return Response.json({ 27 | by: "A_D_E_P_T", 28 | descendants: 191, 29 | id: this.id, 30 | kids: [], 31 | score: 942, 32 | time: 1723209543, 33 | title: "Jake Seliger has died", 34 | type: "story", 35 | url: "https://marginalrevolution.com/marginalrevolution/2024/08/jake-seliger-is-dead.html", 36 | }); 37 | } 38 | 39 | return Response.error(); 40 | } 41 | }, 42 | }, 43 | ], 44 | ], 45 | }); 46 | 47 | const hn = testbed.inject(HnService); 48 | 49 | const res = await hn.getTopStories(); 50 | 51 | assert.deepStrictEqual( 52 | res.map((item) => item.id), 53 | [1, 2, 3, 4, 5], 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /integration/hacker-news/src/services/hn.service.ts: -------------------------------------------------------------------------------- 1 | import { StaticToken, inject, injectable } from "@joist/di"; 2 | 3 | export interface HnItem { 4 | number: number; 5 | by: string; 6 | descendants: number; 7 | id: number; 8 | kids: number[]; 9 | score: number; 10 | time: number; 11 | title: string; 12 | type: string; 13 | url?: string; 14 | } 15 | 16 | export const HN_API = new StaticToken( 17 | "HN_API", 18 | async () => "https://hacker-news.firebaseio.com", 19 | ); 20 | export const HTTP = new StaticToken("HTTP", async () => 21 | import("./http.service.js").then((m) => new m.HttpService()), 22 | ); 23 | 24 | @injectable() 25 | export class HnService { 26 | #http = inject(HTTP); 27 | #hnApi = inject(HN_API); 28 | 29 | async getTopStories(count = 15) { 30 | const http = await this.#http(); 31 | const hnApi = await this.#hnApi(); 32 | 33 | return this.getTopStoryIds(count).then((res) => { 34 | const storyRequests = res.map((id) => { 35 | return http.fetchJson(`${hnApi}/v0/item/${id}.json`); 36 | }); 37 | 38 | return Promise.allSettled(storyRequests).then((res) => 39 | res 40 | .filter((item) => item.status === "fulfilled") 41 | .map((item, index) => ({ ...item.value, number: index + 1 })), 42 | ); 43 | }); 44 | } 45 | 46 | async getTopStoryIds(count: number) { 47 | const http = await this.#http(); 48 | const hnApi = await this.#hnApi(); 49 | 50 | const url = new URL(`${hnApi}/v0/beststories.json`); 51 | url.searchParams.set("limitToFirst", count.toString()); 52 | url.searchParams.set("orderBy", '"$key"'); 53 | 54 | return http.fetchJson(url); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /integration/hacker-news/src/services/http.service.ts: -------------------------------------------------------------------------------- 1 | export class HttpService { 2 | fetch(input: RequestInfo | URL, init?: RequestInit): Promise { 3 | return fetch(input, init); 4 | } 5 | 6 | fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { 7 | return this.fetch(input, init).then((res) => res.json()); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /integration/hacker-news/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "target" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /integration/hacker-news/wtr.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | rootDir: "../../", 3 | nodeResolve: { 4 | exportConditions: ["production"], 5 | }, 6 | files: "target/**/*.test.js", 7 | }; 8 | -------------------------------------------------------------------------------- /integration/templating/browser-sync.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ["*.html"], 3 | server: { 4 | baseDir: "./", 5 | routes: { 6 | "/node_modules": "../../node_modules", 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /integration/templating/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 24 | 25 | Template Test 26 | 27 | 28 | 29 | 59 | 60 |
61 | 62 | 63 |
64 | 65 | 66 | 69 | 70 | 71 | 72 | 81 | 82 | 83 | remaining 84 | 85 | 146 | 147 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /integration/templating/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@integration/templating", 3 | "version": "3.0.0", 4 | "private": true, 5 | "description": "", 6 | "keywords": [], 7 | "author": "deebloo", 8 | "license": "MIT", 9 | "scripts": { 10 | "start": "wireit", 11 | "build": "wireit" 12 | }, 13 | "wireit": { 14 | "start": { 15 | "command": "browser-sync start --config=browser-sync.js", 16 | "service": true, 17 | "dependencies": [ 18 | { 19 | "script": "build", 20 | "cascade": false 21 | } 22 | ] 23 | }, 24 | "build": { 25 | "dependencies": [ 26 | "../../packages/element:build", 27 | "../../packages/di:build", 28 | "../../packages/observable:build", 29 | "../../packages/templating:build" 30 | ] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /integration/todo/browser-sync.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ["*.html", "target/**", "../../packages/**/target/**"], 3 | server: { 4 | baseDir: "./", 5 | routes: { 6 | "/node_modules": "../../node_modules", 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /integration/todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 25 | 26 | Todo App 27 | 28 | 29 | 30 | 115 | 116 |
117 |

todos

118 |
119 | 120 | 121 | 122 | 123 | 124 |
125 |
126 | 127 | 128 | 129 | 130 |
131 | 132 | 139 | 140 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /integration/todo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@integration/todo", 3 | "version": "2.0.0", 4 | "sideEffects": false, 5 | "private": true, 6 | "description": "", 7 | "type": "module", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/deebloo/joist.git" 11 | }, 12 | "keywords": [], 13 | "author": "deebloo", 14 | "license": "MIT", 15 | "scripts": { 16 | "start": "wireit", 17 | "build": "wireit" 18 | }, 19 | "wireit": { 20 | "start": { 21 | "command": "browser-sync start --config=browser-sync.cjs", 22 | "service": true, 23 | "dependencies": [ 24 | { 25 | "script": "build", 26 | "cascade": false 27 | } 28 | ] 29 | }, 30 | "build": { 31 | "command": "tsc --build --pretty", 32 | "clean": "if-file-deleted", 33 | "files": [ 34 | "src/**", 35 | "tsconfig.json", 36 | "../../tsconfig.json" 37 | ], 38 | "output": [ 39 | "target/**", 40 | "tsconfig.tsbuildinfo" 41 | ], 42 | "dependencies": [ 43 | "../../packages/element:build", 44 | "../../packages/di:build", 45 | "../../packages/observable:build", 46 | "../../packages/templating:build" 47 | ] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /integration/todo/src/ctx/storage.ctx.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "@joist/di"; 2 | import { css, element, html } from "@joist/element"; 3 | 4 | import { AppStorage, type Storage } from "../services/storage.service.js"; 5 | import { TodoService } from "../services/todo.service.js"; 6 | 7 | class AppLocalStorage implements Storage { 8 | async loadJSON(key: string): Promise { 9 | try { 10 | const res = localStorage.getItem(key); 11 | 12 | if (res) { 13 | return JSON.parse(res); 14 | } 15 | } catch {} 16 | 17 | return undefined; 18 | } 19 | 20 | async saveJSON(key: string, val: T): Promise { 21 | try { 22 | localStorage.setItem(key, JSON.stringify(val)); 23 | 24 | return true; 25 | } catch { 26 | return false; 27 | } 28 | } 29 | } 30 | 31 | @element({ 32 | tagName: "local-storage-ctx", 33 | shadowDom: [ 34 | css` 35 | :host { 36 | display: contents; 37 | } 38 | `, 39 | html``, 40 | ], 41 | }) 42 | @injectable({ 43 | providers: [ 44 | [TodoService, { use: TodoService }], 45 | [AppStorage, { use: AppLocalStorage }], 46 | ], 47 | }) 48 | export class LocalStorageCtx extends HTMLElement {} 49 | -------------------------------------------------------------------------------- /integration/todo/src/elements/todo-card.element.ts: -------------------------------------------------------------------------------- 1 | import { attr, css, element, html, listen } from "@joist/element"; 2 | import { bind } from "@joist/templating"; 3 | import { effect, observe } from "@joist/observable"; 4 | 5 | import type { TodoStatus } from "../services/todo.service.js"; 6 | 7 | @element({ 8 | tagName: "todo-card", 9 | shadowDom: [ 10 | css` 11 | :host { 12 | align-items: center; 13 | display: flex; 14 | padding: 1rem; 15 | border-bottom: solid 1px #f3f3f3; 16 | } 17 | 18 | #name { 19 | flex-grow: 1; 20 | } 21 | 22 | :host([status="complete"]) #name { 23 | text-decoration: line-through; 24 | opacity: 0.5; 25 | } 26 | 27 | button { 28 | border: none; 29 | color: cornflowerblue; 30 | cursor: pointer; 31 | font-size: 1rem; 32 | background: none; 33 | margin-left: 0.5rem; 34 | } 35 | 36 | button#remove { 37 | color: darkred; 38 | } 39 | `, 40 | html` 41 |
42 | 43 | 46 | 47 | 48 | 49 |
50 | 51 | 52 | 53 | 56 | `, 57 | ], 58 | }) 59 | export class TodoCardElement extends HTMLElement { 60 | @attr() 61 | @observe() 62 | accessor status: TodoStatus = "active"; 63 | 64 | @bind({ 65 | compute(i) { 66 | return { 67 | value: i.status === "active" ? "complete" : "active", 68 | showStar: i.status === "complete", 69 | }; 70 | }, 71 | }) 72 | accessor actionState = { 73 | value: "active", 74 | showStar: false, 75 | }; 76 | 77 | @effect() 78 | onStatusChange() { 79 | console.log("STATUS CHANGE", this.status); 80 | } 81 | 82 | @listen("click", "#complete") 83 | onClick() { 84 | this.dispatchEvent(new Event("complete", { bubbles: true })); 85 | } 86 | 87 | @listen("click", "#remove") 88 | onRemove() { 89 | this.dispatchEvent(new Event("remove", { bubbles: true })); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /integration/todo/src/elements/todo-form.element.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from "@joist/di"; 2 | import { css, element, html, listen, query } from "@joist/element"; 3 | 4 | import { Todo, TodoService } from "../services/todo.service.js"; 5 | 6 | @injectable() 7 | @element({ 8 | tagName: "todo-form", 9 | shadowDom: [ 10 | css` 11 | :host { 12 | display: block; 13 | background: #fff; 14 | } 15 | 16 | input { 17 | box-sizing: border-box; 18 | display: block; 19 | padding: 1rem; 20 | border: none; 21 | background: rgba(0, 0, 0, 0.003); 22 | box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); 23 | margin: 0; 24 | width: 100%; 25 | font-size: 24px; 26 | line-height: 1.4em; 27 | border: 0; 28 | -webkit-font-smoothing: antialiased; 29 | -moz-osx-font-smoothing: grayscale; 30 | } 31 | 32 | :focus { 33 | outline: none; 34 | } 35 | 36 | ::-webkit-input-placeholder { 37 | font-style: italic; 38 | font-weight: 300; 39 | color: #e6e6e6; 40 | } 41 | 42 | ::-moz-placeholder { 43 | font-style: italic; 44 | font-weight: 300; 45 | color: #e6e6e6; 46 | } 47 | 48 | ::input-placeholder { 49 | font-style: italic; 50 | font-weight: 300; 51 | color: #e6e6e6; 52 | } 53 | `, 54 | html` 55 |
56 | 63 |
64 | `, 65 | ], 66 | }) 67 | export class TodoFormElement extends HTMLElement { 68 | #input = query("#input"); 69 | 70 | #todos = inject(TodoService); 71 | 72 | @listen("submit", "#todo-form") 73 | onSubmit(e: Event) { 74 | const service = this.#todos(); 75 | 76 | e.preventDefault(); 77 | 78 | const input = this.#input(); 79 | 80 | if (input.value) { 81 | service.addTodo(Todo.create(input.value, "active")); 82 | 83 | input.value = ""; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /integration/todo/src/elements/todo-list-footer.element.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from "@joist/di"; 2 | import { css, element, html } from "@joist/element"; 3 | import { bind } from "@joist/templating"; 4 | 5 | import { TodoService } from "../services/todo.service.js"; 6 | 7 | const sfxs = new Map([ 8 | ["one", "item"], 9 | ["other", "items"], 10 | ]); 11 | 12 | class PluralRules extends Intl.PluralRules {} 13 | 14 | @injectable({ 15 | providers: [ 16 | [ 17 | PluralRules, 18 | { 19 | factory() { 20 | return new Intl.PluralRules(); 21 | }, 22 | }, 23 | ], 24 | ], 25 | }) 26 | @element({ 27 | tagName: "todo-list-footer", 28 | shadowDom: [ 29 | css` 30 | :host { 31 | --card-height: 50px; 32 | 33 | display: block; 34 | position: relative; 35 | height: var(--card-height); 36 | } 37 | 38 | #footer { 39 | box-sizing: border-box; 40 | background: white; 41 | display: flex; 42 | align-items: center; 43 | color: black; 44 | padding: 10px 15px; 45 | height: calc(var(--card-height) - 11px); 46 | text-align: center; 47 | font-size: 14px; 48 | text-align: left; 49 | position: relative; 50 | z-index: 1; 51 | } 52 | 53 | #decoration { 54 | background: white; 55 | content: ""; 56 | position: absolute; 57 | top: 0; 58 | right: 0; 59 | bottom: 0; 60 | left: 0; 61 | height: calc(var(--card-height) - 11px); 62 | overflow: hidden; 63 | box-shadow: 64 | 0 1px 1px rgba(0, 0, 0, 0.2), 65 | 0 8px 0 -3px #f6f6f6, 66 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 67 | 0 16px 0 -6px #f6f6f6, 68 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 69 | } 70 | `, 71 | html` 72 | 73 | 74 |
75 | `, 76 | ], 77 | }) 78 | export class TodoListFooterElement extends HTMLElement { 79 | #todo = inject(TodoService); 80 | #pr = inject(PluralRules); 81 | #controller = new AbortController(); 82 | 83 | @bind() 84 | accessor totalActive = "0 items"; 85 | 86 | connectedCallback() { 87 | const todo = this.#todo(); 88 | 89 | const onTodoUpdate = async () => { 90 | this.totalActive = `${todo.totalActive} ${sfxs.get(this.#pr().select(todo.totalActive))}`; 91 | }; 92 | 93 | onTodoUpdate(); 94 | 95 | todo.addEventListener("todo_sync", onTodoUpdate, { 96 | signal: this.#controller.signal, 97 | }); 98 | } 99 | 100 | disconnectedCallback() { 101 | this.#controller.abort(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /integration/todo/src/elements/todo-list.element.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from "@joist/di"; 2 | import { css, element, html, listen } from "@joist/element"; 3 | import { bind } from "@joist/templating"; 4 | 5 | import { type Todo, TodoService } from "../services/todo.service.js"; 6 | import { TodoCardElement } from "./todo-card.element.js"; 7 | 8 | @injectable() 9 | @element({ 10 | tagName: "todo-list", 11 | shadowDom: [ 12 | css` 13 | :host { 14 | display: block; 15 | background: #fff; 16 | position: relative; 17 | } 18 | `, 19 | html` 20 | 21 | 28 | 29 | `, 30 | ], 31 | }) 32 | export class TodoListElement extends HTMLElement { 33 | #todo = inject(TodoService); 34 | 35 | @bind() 36 | accessor todos: Todo[] = []; 37 | 38 | async connectedCallback() { 39 | const service = this.#todo(); 40 | 41 | this.todos = await service.getTodos(); 42 | 43 | service.addEventListener("todo_sync", () => { 44 | this.todos = service.todos; 45 | }); 46 | } 47 | 48 | @listen("remove") 49 | onRemove(e: Event) { 50 | if (e.target instanceof TodoCardElement) { 51 | const service = this.#todo(); 52 | service.removeTodo(e.target.id); 53 | } 54 | } 55 | 56 | @listen("complete") 57 | onComplete(e: Event) { 58 | if (e.target instanceof TodoCardElement) { 59 | const service = this.#todo(); 60 | 61 | const status = e.target.getAttribute("status"); 62 | 63 | service.updateTodo(e.target.id, { 64 | status: status === "active" ? "complete" : "active", 65 | }); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /integration/todo/src/elements/todo-logger.element.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from "@joist/di"; 2 | import { element } from "@joist/element"; 3 | 4 | import { TodoService } from "../services/todo.service.js"; 5 | 6 | @injectable() 7 | @element({ 8 | tagName: "todo-logger", 9 | }) 10 | export class TodoLoggerElement extends HTMLElement { 11 | #todo = inject(TodoService); 12 | 13 | async connectedCallback() { 14 | const service = this.#todo(); 15 | 16 | console.log(await service.getTodos()); 17 | 18 | service.addEventListener("todo_added", console.log); 19 | service.addEventListener("todo_removed", console.log); 20 | service.addEventListener("todo_updated", console.log); 21 | } 22 | 23 | disconnectedCallback() { 24 | const service = this.#todo(); 25 | 26 | service.removeEventListener("todo_added", console.log); 27 | service.removeEventListener("todo_removed", console.log); 28 | service.removeEventListener("todo_updated", console.log); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /integration/todo/src/services/storage.service.ts: -------------------------------------------------------------------------------- 1 | export interface Storage { 2 | loadJSON(key: string): Promise; 3 | saveJSON(key: string, val: T): Promise; 4 | } 5 | 6 | export class AppStorage implements Storage { 7 | static service = true; 8 | 9 | #data = new Map(); 10 | 11 | async loadJSON(key: string): Promise { 12 | return this.#data.get(key) as T | undefined; 13 | } 14 | 15 | async saveJSON(key: string, val: T): Promise { 16 | this.#data.set(key, val); 17 | 18 | return true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /integration/todo/src/services/todo.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from "@joist/di"; 2 | import { effect, observe } from "@joist/observable"; 3 | 4 | import { AppStorage } from "./storage.service.js"; 5 | 6 | export type TodoStatus = "active" | "complete"; 7 | 8 | export class Todo { 9 | static create(name: string, status: TodoStatus) { 10 | return new Todo(crypto.randomUUID(), name, status); 11 | } 12 | 13 | constructor( 14 | public readonly id: string, 15 | public readonly name: string, 16 | public readonly status: TodoStatus, 17 | ) {} 18 | } 19 | 20 | export class TodoSyncEvent extends Event { 21 | constructor(public todos: Todo[]) { 22 | super("todo_sync"); 23 | } 24 | } 25 | 26 | @injectable() 27 | export class TodoService extends EventTarget { 28 | @observe() 29 | accessor #todos: Todo[] = []; 30 | 31 | accessor #initialized = false; 32 | 33 | totalActive = 0; 34 | 35 | get todos() { 36 | return this.#todos; 37 | } 38 | 39 | #store = inject(AppStorage); 40 | 41 | @effect() 42 | syncTodosToStorage() { 43 | this.#store().saveJSON("joist_todo", this.#todos); 44 | 45 | this.totalActive = this.#todos.reduce( 46 | (total, todo) => (todo.status === "active" ? total + 1 : total), 47 | 0, 48 | ); 49 | 50 | this.dispatchEvent(new TodoSyncEvent(this.#todos)); 51 | } 52 | 53 | async getTodos(): Promise { 54 | if (this.#initialized) { 55 | return this.#todos; 56 | } 57 | 58 | return this.#store() 59 | .loadJSON("joist_todo") 60 | .then((todos) => { 61 | this.#initialized = true; 62 | 63 | if (todos) { 64 | this.#todos = todos; 65 | } 66 | 67 | return this.#todos; 68 | }); 69 | } 70 | 71 | addTodo(todo: Todo) { 72 | this.#todos = [...this.#todos, todo]; 73 | } 74 | 75 | removeTodo(id: string) { 76 | this.#todos = this.#todos.filter((todo) => todo.id !== id); 77 | } 78 | 79 | updateTodo(id: string, patch: Partial) { 80 | this.#todos = this.#todos.map((todo) => { 81 | if (todo.id === id) { 82 | return { ...todo, ...patch }; 83 | } 84 | 85 | return todo; 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /integration/todo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "target" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joist", 3 | "version": "4.2.4-next.7", 4 | "description": "", 5 | "type": "module", 6 | "author": "deebloo", 7 | "license": "MIT", 8 | "workspaces": [ 9 | "packages/**", 10 | "integration/**", 11 | "website" 12 | ], 13 | "scripts": { 14 | "test": "wireit", 15 | "build": "wireit", 16 | "prepare": "husky && husky install" 17 | }, 18 | "wireit": { 19 | "build": { 20 | "dependencies": [ 21 | "./packages/di:build", 22 | "./packages/element:build", 23 | "./packages/observable:build", 24 | "./packages/ssr:build", 25 | "./integration/counter:build", 26 | "./integration/hacker-news:build", 27 | "./integration/templating:build", 28 | "./integration/todo:build" 29 | ] 30 | }, 31 | "test": { 32 | "dependencies": [ 33 | "./packages/di:test", 34 | "./packages/element:test", 35 | "./packages/observable:test", 36 | "./packages/ssr:test", 37 | "./packages/templating:test", 38 | "./integration/hacker-news:test" 39 | ] 40 | } 41 | }, 42 | "devDependencies": { 43 | "@open-wc/testing": "^4.0.0", 44 | "@types/chai": "^5.0.1", 45 | "@types/mocha": "^10.0.10", 46 | "@types/node": "^22.10.6", 47 | "@web/test-runner": "^0.20.0", 48 | "browser-sync": "^3.0.3", 49 | "chai": "^5.1.1", 50 | "husky": "^9.0.11", 51 | "lint-staged": "^16.0.0", 52 | "mocha": "^11.0.0", 53 | "prettier": "^3.5.3", 54 | "tslib": "2.8.1", 55 | "typescript": "^5.7.3", 56 | "wireit": "^0.14.0" 57 | }, 58 | "lint-staged": { 59 | "*.{js,css,md,html}": "prettier --write" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/di/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2020 Danny Blue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /packages/di/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joist/di", 3 | "version": "4.2.4-next.7", 4 | "type": "module", 5 | "main": "./target/lib.js", 6 | "module": "./target/lib.js", 7 | "exports": { 8 | ".": "./target/lib.js", 9 | "./*": "./target/lib/*", 10 | "./package.json": "./package.json" 11 | }, 12 | "files": [ 13 | "src", 14 | "target" 15 | ], 16 | "sideEffects": false, 17 | "description": "Dependency Injection for Vanilla JS classes", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/joist-framework/joist.git" 21 | }, 22 | "keywords": [ 23 | "TypeScript", 24 | "DI", 25 | "Dependency Injection", 26 | "WebComponents" 27 | ], 28 | "author": "deebloo", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/joist-framework/joist/issues" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "scripts": { 37 | "test": "wireit", 38 | "build": "wireit" 39 | }, 40 | "wireit": { 41 | "build": { 42 | "command": "tsc --build --pretty", 43 | "clean": "if-file-deleted", 44 | "files": [ 45 | "src/**", 46 | "tsconfig.json", 47 | "../../tsconfig.json" 48 | ], 49 | "output": [ 50 | "target/**", 51 | "tsconfig.tsbuildinfo" 52 | ] 53 | }, 54 | "test": { 55 | "command": "wtr --config wtr.config.mjs", 56 | "files": [ 57 | "wtr.config.mjs", 58 | "target/**" 59 | ], 60 | "output": [], 61 | "dependencies": [ 62 | "build" 63 | ] 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/di/src/lib.ts: -------------------------------------------------------------------------------- 1 | export { Injector } from "./lib/injector.js"; 2 | export { 3 | Provider, 4 | ConstructableToken, 5 | StaticToken, 6 | InjectionToken, 7 | } from "./lib/provider.js"; 8 | export { injectable } from "./lib/injectable.js"; 9 | export { inject, injectAll, Injected } from "./lib/inject.js"; 10 | export { injected, created } from "./lib/lifecycle.js"; 11 | export { DOMInjector } from "./lib/dom/dom-injector.js"; 12 | -------------------------------------------------------------------------------- /packages/di/src/lib/context/injector.ts: -------------------------------------------------------------------------------- 1 | import { type Context, createContext } from "./protocol.js"; 2 | 3 | import type { Injector } from "../injector.js"; 4 | 5 | export const INJECTOR_CTX: Context<"injector", Injector> = 6 | createContext("injector"); 7 | -------------------------------------------------------------------------------- /packages/di/src/lib/context/protocol.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A context key. 3 | * 4 | * A context key can be any type of object, including strings and symbols. The 5 | * Context type brands the key type with the `__context__` property that 6 | * carries the type of the value the context references. 7 | */ 8 | export type Context = KeyType & { __context__: ValueType }; 9 | 10 | /** 11 | * An unknown context type 12 | */ 13 | export type UnknownContext = Context; 14 | 15 | /** 16 | * A helper type which can extract a Context value type from a Context type 17 | */ 18 | export type ContextType = T extends Context< 19 | infer _, 20 | infer V 21 | > 22 | ? V 23 | : never; 24 | 25 | /** 26 | * A function which creates a Context value object 27 | */ 28 | 29 | export function createContext(key: KeyType) { 30 | return key as Context; 31 | } 32 | 33 | /** 34 | * A callback which is provided by a context requester and is called with the value satisfying the request. 35 | * This callback can be called multiple times by context providers as the requested value is changed. 36 | */ 37 | export type ContextCallback = ( 38 | value: ValueType, 39 | unsubscribe?: () => void, 40 | ) => void; 41 | 42 | /** 43 | * An event fired by a context requester to signal it desires a named context. 44 | * 45 | * A provider should inspect the `context` property of the event to determine if it has a value that can 46 | * satisfy the request, calling the `callback` with the requested value if so. 47 | * 48 | * If the requested context event contains a truthy `subscribe` value, then a provider can call the callback 49 | * multiple times if the value is changed, if this is the case the provider should pass an `unsubscribe` 50 | * function to the callback which requesters can invoke to indicate they no longer wish to receive these updates. 51 | */ 52 | export class ContextRequestEvent extends Event { 53 | context: T; 54 | callback: ContextCallback>; 55 | subscribe?: boolean; 56 | 57 | public constructor( 58 | context: T, 59 | callback: ContextCallback>, 60 | subscribe?: boolean, 61 | ) { 62 | super("context-request", { bubbles: true, composed: true }); 63 | 64 | this.context = context; 65 | this.callback = callback; 66 | this.subscribe = subscribe; 67 | } 68 | } 69 | 70 | declare global { 71 | interface HTMLElementEventMap { 72 | /** 73 | * A 'context-request' event can be emitted by any element which desires 74 | * a context value to be injected by an external provider. 75 | */ 76 | "context-request": ContextRequestEvent>; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/di/src/lib/dom/dom-injector.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { INJECTOR_CTX } from "../context/injector.js"; 4 | import { 5 | ContextRequestEvent, 6 | type UnknownContext, 7 | } from "../context/protocol.js"; 8 | import { Injector } from "../injector.js"; 9 | import { DOMInjector } from "./dom-injector.js"; 10 | 11 | it("should respond to elements looking for an injector", () => { 12 | const injector = new DOMInjector(); 13 | injector.attach(document.body); 14 | 15 | const host = document.createElement("div"); 16 | document.body.append(host); 17 | 18 | let parent: Injector | null = null; 19 | 20 | host.dispatchEvent( 21 | new ContextRequestEvent(INJECTOR_CTX, (i) => { 22 | parent = i; 23 | }), 24 | ); 25 | 26 | assert.equal(parent, injector); 27 | 28 | injector.detach(); 29 | host.remove(); 30 | }); 31 | 32 | it("should send request looking for other injector contexts", () => { 33 | const parent = new Injector(); 34 | const injector = new DOMInjector(); 35 | 36 | const cb = (e: ContextRequestEvent) => { 37 | if (e.context === INJECTOR_CTX) { 38 | e.callback(parent); 39 | } 40 | }; 41 | 42 | document.body.addEventListener("context-request", cb); 43 | 44 | injector.attach(document.body); 45 | 46 | assert.equal(injector.parent, parent); 47 | 48 | injector.detach(); 49 | document.body.removeEventListener("context-request", cb); 50 | }); 51 | 52 | it("should throw an error if attempting to attach an already attached DOMInjector", () => { 53 | const injector = new DOMInjector(); 54 | 55 | const el = document.createElement("div"); 56 | 57 | injector.attach(el); 58 | 59 | assert.throw(() => { 60 | injector.attach(el); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/di/src/lib/dom/dom-injector.ts: -------------------------------------------------------------------------------- 1 | import { INJECTOR_CTX } from "../context/injector.js"; 2 | import { 3 | ContextRequestEvent, 4 | type UnknownContext, 5 | } from "../context/protocol.js"; 6 | import { Injector } from "../injector.js"; 7 | 8 | /** 9 | * Special Injector that allows you to register an injector with a particular DOM element. 10 | */ 11 | export class DOMInjector extends Injector { 12 | #element: HTMLElement | null = null; 13 | #controller: AbortController | null = null; 14 | 15 | get isAttached(): boolean { 16 | return this.#element !== null && this.#controller !== null; 17 | } 18 | 19 | attach(element: HTMLElement): void { 20 | if (this.isAttached) { 21 | throw new Error( 22 | `This DOMInjector is already attached to ${this.#element}. Detach first before attaching again`, 23 | ); 24 | } 25 | 26 | this.#element = element; 27 | this.#controller = new AbortController(); 28 | 29 | this.#element.addEventListener( 30 | "context-request", 31 | (e: ContextRequestEvent) => { 32 | if (e.context === INJECTOR_CTX) { 33 | if (e.target !== element) { 34 | e.stopPropagation(); 35 | 36 | e.callback(this); 37 | } 38 | } 39 | }, 40 | { signal: this.#controller.signal }, 41 | ); 42 | 43 | this.#element.dispatchEvent( 44 | new ContextRequestEvent(INJECTOR_CTX, (parent) => { 45 | this.parent = parent; 46 | }), 47 | ); 48 | } 49 | 50 | detach(): void { 51 | if (this.#controller) { 52 | this.#controller.abort(); 53 | } 54 | 55 | this.#element = null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/di/src/lib/dom/injectable-el.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { inject } from "../inject.js"; 4 | import { injectable } from "../injectable.js"; 5 | import { DOMInjector } from "./dom-injector.js"; 6 | 7 | it("should allow services to be injected into custom element", () => { 8 | class Foo {} 9 | 10 | @injectable() 11 | class MyElement extends HTMLElement { 12 | foo = inject(Foo); 13 | } 14 | 15 | customElements.define("injectable-1", MyElement); 16 | 17 | const el = new MyElement(); 18 | 19 | assert.instanceOf(el.foo(), Foo); 20 | }); 21 | 22 | it("should allow services to be injected into custom elements that has been extended", () => { 23 | class Foo {} 24 | 25 | class MyBaseElement extends HTMLElement {} 26 | 27 | @injectable() 28 | class MyElement extends MyBaseElement { 29 | foo = inject(Foo); 30 | } 31 | 32 | customElements.define("injectable-2", MyElement); 33 | 34 | const el = new MyElement(); 35 | 36 | assert.instanceOf(el.foo(), Foo); 37 | }); 38 | 39 | it("should handle parent HTML Injectors", async () => { 40 | @injectable() 41 | class A {} 42 | 43 | @injectable() 44 | class B { 45 | a = inject(A); 46 | } 47 | 48 | class AltA implements A {} 49 | 50 | @injectable({ 51 | providers: [ 52 | [B, { use: B }], 53 | [A, { use: AltA }], 54 | ], 55 | }) 56 | class Parent extends HTMLElement {} 57 | 58 | @injectable() 59 | class Child extends HTMLElement { 60 | b = inject(B); 61 | } 62 | 63 | customElements.define("injectable-parent-1", Parent); 64 | customElements.define("injectable-child-1", Child); 65 | 66 | const el = document.createElement("div"); 67 | el.innerHTML = /*html*/ ` 68 | 69 | 70 | 71 | `; 72 | 73 | document.body.append(el); 74 | 75 | const child = el.querySelector("injectable-child-1"); 76 | 77 | assert.instanceOf(child?.b().a(), AltA); 78 | 79 | el.remove(); 80 | }); 81 | 82 | it("should handle changing contexts", async () => { 83 | class A {} 84 | class AltA implements A {} 85 | 86 | @injectable({ 87 | providers: [[A, { use: A }]], 88 | }) 89 | class Ctx1 extends HTMLElement {} 90 | 91 | @injectable({ 92 | providers: [[A, { use: AltA }]], 93 | }) 94 | class Ctx2 extends HTMLElement {} 95 | 96 | @injectable() 97 | class Child extends HTMLElement { 98 | a = inject(A); 99 | } 100 | 101 | customElements.define("ctx-1", Ctx1); 102 | customElements.define("ctx-2", Ctx2); 103 | customElements.define("ctx-child", Child); 104 | 105 | const el = document.createElement("div"); 106 | el.innerHTML = /*html*/ ` 107 |
108 | 109 | 110 | 111 | 112 | 113 |
114 | `; 115 | 116 | document.body.append(el); 117 | 118 | const ctx2 = el.querySelector("ctx-2"); 119 | 120 | let child = el.querySelector("ctx-child"); 121 | 122 | assert.instanceOf(child?.a(), A); 123 | 124 | child.remove(); 125 | 126 | ctx2?.append(child); 127 | 128 | child = el.querySelector("ctx-child"); 129 | 130 | assert.instanceOf(child?.a(), AltA); 131 | }); 132 | 133 | it("should provide the same context in disconnectedCallback as connectedCallback", async () => { 134 | class A {} 135 | 136 | class AltA {} 137 | 138 | const app = new DOMInjector({ 139 | providers: [[A, { use: AltA }]], 140 | }); 141 | 142 | app.attach(document.body); 143 | 144 | @injectable() 145 | class Example extends HTMLElement { 146 | #ctx = inject(A); 147 | 148 | connected: A | null = null; 149 | disconnected: A | null = null; 150 | 151 | connectedCallback(): void { 152 | this.connected = this.#ctx(); 153 | } 154 | 155 | disconnectedCallback(): void { 156 | this.disconnected = this.#ctx(); 157 | } 158 | } 159 | 160 | customElements.define("ctx-3", Example); 161 | 162 | const el = document.createElement("ctx-3") as Example; 163 | 164 | document.body.append(el); 165 | 166 | assert.instanceOf(el.connected, AltA); 167 | 168 | el.remove(); 169 | 170 | assert.instanceOf(el.disconnected, AltA); 171 | 172 | assert.equal(el.connected, el.disconnected); 173 | 174 | app.detach(); 175 | }); 176 | -------------------------------------------------------------------------------- /packages/di/src/lib/dom/injectable-el.ts: -------------------------------------------------------------------------------- 1 | import { INJECTOR_CTX } from "../context/injector.js"; 2 | import { ContextRequestEvent } from "../context/protocol.js"; 3 | import { INJECTOR } from "../injector.js"; 4 | import type { Injector } from "../injector.js"; 5 | import { callLifecycle } from "../lifecycle.js"; 6 | import type { InjectableMetadata } from "../metadata.js"; 7 | import type { ConstructableToken } from "../provider.js"; 8 | 9 | export type InjectableEl = HTMLElement & { [INJECTOR]: Injector }; 10 | 11 | export function injectableEl< 12 | T extends ConstructableToken, 13 | >(Base: T, ctx: ClassDecoratorContext): T { 14 | const metadata: InjectableMetadata = ctx.metadata; 15 | 16 | const def = { 17 | [Base.name]: class extends Base { 18 | constructor(..._: any[]) { 19 | super(); 20 | 21 | const injector = this[INJECTOR]; 22 | 23 | this.addEventListener("context-request", (e) => { 24 | if (e.target !== this && e.context === INJECTOR_CTX) { 25 | e.stopPropagation(); 26 | 27 | e.callback(injector); 28 | } 29 | }); 30 | 31 | callLifecycle(this, injector, metadata?.onCreated); 32 | } 33 | 34 | connectedCallback() { 35 | this.dispatchEvent( 36 | new ContextRequestEvent(INJECTOR_CTX, (ctx) => { 37 | this[INJECTOR].parent = ctx; 38 | }), 39 | ); 40 | 41 | callLifecycle(this, this[INJECTOR], metadata?.onInjected); 42 | 43 | if (super.connectedCallback) { 44 | super.connectedCallback(); 45 | } 46 | } 47 | 48 | disconnectedCallback() { 49 | // super disconnect needs to be called first. 50 | // If not the context could be different since the element will be removed from the injector chain. 51 | // This leads to unexpected behaviors. 52 | if (super.disconnectedCallback) { 53 | super.disconnectedCallback(); 54 | } 55 | 56 | this[INJECTOR].parent = undefined; 57 | } 58 | }, 59 | }; 60 | 61 | return def[Base.name]; 62 | } 63 | -------------------------------------------------------------------------------- /packages/di/src/lib/inject.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { inject, injectAll } from "./inject.js"; 4 | import { injectable } from "./injectable.js"; 5 | import { Injector } from "./injector.js"; 6 | import { StaticToken } from "./provider.js"; 7 | 8 | it("should throw error if called in constructor", () => { 9 | assert.throws(() => { 10 | class FooService { 11 | value = "1"; 12 | } 13 | 14 | @injectable() 15 | class BarService { 16 | foo = inject(FooService); 17 | 18 | constructor() { 19 | this.foo(); 20 | } 21 | } 22 | 23 | const parent = new Injector(); 24 | 25 | parent.inject(BarService); 26 | }, "BarService is either not injectable or a service is being called in the constructor."); 27 | }); 28 | 29 | it("should throw error if static token is unavailable", () => { 30 | assert.throws(() => { 31 | const TOKEN = new StaticToken("test"); 32 | 33 | const parent = new Injector(); 34 | 35 | parent.inject(TOKEN); 36 | }, 'Provider not found for "test"'); 37 | }); 38 | 39 | it("should use the calling injector as parent", () => { 40 | class FooService { 41 | value = "1"; 42 | } 43 | 44 | @injectable() 45 | class BarService { 46 | foo = inject(FooService); 47 | } 48 | 49 | const parent = new Injector({ 50 | providers: [ 51 | [ 52 | FooService, 53 | { 54 | use: class extends FooService { 55 | value = "100"; 56 | }, 57 | }, 58 | ], 59 | ], 60 | }); 61 | 62 | assert.strictEqual(parent.inject(BarService).foo().value, "100"); 63 | }); 64 | 65 | it("should inject a static token", () => { 66 | const TOKEN = new StaticToken("test", () => "Hello World"); 67 | 68 | @injectable() 69 | class HelloWorld { 70 | hello = inject(TOKEN); 71 | } 72 | 73 | assert.strictEqual(new HelloWorld().hello(), "Hello World"); 74 | }); 75 | 76 | it("should use the calling injector as parent", () => { 77 | class FooService { 78 | value = "1"; 79 | } 80 | 81 | @injectable() 82 | class BarService { 83 | foo = inject(FooService); 84 | } 85 | 86 | const parent = new Injector({ 87 | providers: [ 88 | [ 89 | FooService, 90 | { 91 | use: class extends FooService { 92 | value = "100"; 93 | }, 94 | }, 95 | ], 96 | ], 97 | }); 98 | 99 | assert.strictEqual(parent.inject(BarService).foo().value, "100"); 100 | }); 101 | 102 | it("should all you to inject all", () => { 103 | const TOKEN = new StaticToken("test", () => "Hello World"); 104 | 105 | @injectable() 106 | class HelloWorld { 107 | hello = injectAll(TOKEN); 108 | } 109 | 110 | assert.deepEqual(new HelloWorld().hello(), ["Hello World"]); 111 | }); 112 | -------------------------------------------------------------------------------- /packages/di/src/lib/inject.ts: -------------------------------------------------------------------------------- 1 | import type { Injector } from "./injector.js"; 2 | import { readInjector } from "./metadata.js"; 3 | import type { InjectionToken } from "./provider.js"; 4 | 5 | export type Injected = () => T; 6 | 7 | /** 8 | * Injects a service into an `injectable` class. 9 | */ 10 | export function inject(token: InjectionToken): Injected { 11 | return internalInject((i) => i.inject(token)); 12 | } 13 | 14 | /** 15 | * Finds and injects ALL instances of a service from the current points up. 16 | */ 17 | export function injectAll(token: InjectionToken): Injected { 18 | return internalInject((i) => i.injectAll(token)); 19 | } 20 | 21 | function internalInject(cb: (i: Injector) => R) { 22 | return function (this: T) { 23 | const injector = readInjector(this); 24 | 25 | if (injector === null) { 26 | throw new Error( 27 | `${this.constructor.name} is either not injectable or a service is being called in the constructor. \n Either add the @injectable() to your class or use the @injected callback method.`, 28 | ); 29 | } 30 | 31 | return cb(injector); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/di/src/lib/injectable.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { inject } from "./inject.js"; 4 | import { injectable } from "./injectable.js"; 5 | import { Injector } from "./injector.js"; 6 | import { readInjector } from "./metadata.js"; 7 | import { StaticToken } from "./provider.js"; 8 | 9 | it("should locally override a provider", () => { 10 | class Foo {} 11 | 12 | class Bar extends Foo {} 13 | 14 | @injectable({ 15 | providers: [[Foo, { use: Bar }]], 16 | }) 17 | class MyService { 18 | foo = inject(Foo); 19 | } 20 | 21 | const el = new MyService(); 22 | 23 | assert.instanceOf(el.foo(), Bar); 24 | }); 25 | 26 | it("should define an injector for a service instance", () => { 27 | @injectable() 28 | class MyService { 29 | constructor(public arg = "a") {} 30 | } 31 | 32 | const instance = new MyService("b"); 33 | 34 | assert.ok(readInjector(instance)); 35 | assert.ok(instance.arg === "b"); 36 | }); 37 | 38 | it("should inject the current service injectable instance", () => { 39 | @injectable() 40 | class MyService { 41 | injector = inject(Injector); 42 | } 43 | 44 | const app = new Injector(); 45 | const service = app.inject(MyService); 46 | 47 | assert.equal(service.injector(), readInjector(service)); 48 | }); 49 | 50 | it("should not override the name of the original class", () => { 51 | @injectable() 52 | class MyService {} 53 | 54 | assert.equal(MyService.name, "MyService"); 55 | }); 56 | 57 | it("should provide itself for spefified tokens", () => { 58 | const TOKEN = new StaticToken("MY_TOKEN"); 59 | 60 | @injectable({ 61 | provideSelfAs: [TOKEN], 62 | }) 63 | class MyService { 64 | value = inject(TOKEN); 65 | } 66 | 67 | const service = new MyService(); 68 | 69 | assert.equal(service.value(), service); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/di/src/lib/injectable.ts: -------------------------------------------------------------------------------- 1 | import { injectableEl } from "./dom/injectable-el.js"; 2 | import { INJECTOR, Injector } from "./injector.js"; 3 | import type { 4 | ConstructableToken, 5 | InjectionToken, 6 | Provider, 7 | } from "./provider.js"; 8 | 9 | export interface InjectableOpts { 10 | name?: string; 11 | providers?: Iterable>; 12 | provideSelfAs?: InjectionToken[]; 13 | } 14 | 15 | export function injectable(opts?: InjectableOpts) { 16 | return function injectableDecorator>( 17 | Base: T, 18 | ctx: ClassDecoratorContext, 19 | ): T { 20 | const def = { 21 | [Base.name]: class extends Base { 22 | [INJECTOR]: Injector; 23 | 24 | constructor(...args: any[]) { 25 | super(...args); 26 | 27 | this[INJECTOR] = new Injector(opts); 28 | 29 | this[INJECTOR].providers.set(Injector, { 30 | factory: () => this[INJECTOR], 31 | }); 32 | 33 | if (opts?.provideSelfAs) { 34 | for (const token of opts.provideSelfAs) { 35 | this[INJECTOR].providers.set(token, { 36 | factory: () => this, 37 | }); 38 | } 39 | } 40 | } 41 | }, 42 | }; 43 | 44 | // Only apply custom element bootstrap logic if the decorated class is an HTMLElement 45 | if ("HTMLElement" in globalThis) { 46 | if ( 47 | Object.prototype.isPrototypeOf.call( 48 | HTMLElement.prototype, 49 | Base.prototype, 50 | ) 51 | ) { 52 | return injectableEl(def[Base.name], ctx); 53 | } 54 | } 55 | 56 | return def[Base.name]; 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /packages/di/src/lib/injector.ts: -------------------------------------------------------------------------------- 1 | import { callLifecycle } from "./lifecycle.js"; 2 | import { readInjector, readMetadata } from "./metadata.js"; 3 | import { 4 | type InjectionToken, 5 | type Provider, 6 | type ProviderDef, 7 | type ProviderFactory, 8 | StaticToken, 9 | } from "./provider.js"; 10 | 11 | export interface InjectorOpts { 12 | name?: string; 13 | providers?: Iterable>; 14 | parent?: Injector; 15 | } 16 | 17 | export const INJECTOR: unique symbol = Symbol("JOIST_INJECTOR"); 18 | 19 | export class ProviderMap extends Map, ProviderDef> {} 20 | 21 | /** 22 | * Injectors create and store instances of services. 23 | * A service is any constructable class. 24 | * When calling Injector.get, the injector will resolve as following. 25 | * 26 | * 1. Do I have a cached instance locally? 27 | * 2. Do I have a local provider definition for the token? 28 | * 3. Do I have a parent? Check parent for 1 and 2 29 | * 5. All clear, go ahead and construct and cache the requested service 30 | * ``` 31 | * RootInjector |--> InjectorA |--> InjectorB 32 | * |--> InjectorC 33 | * |--> InjectorD |--> InjectorE 34 | * ``` 35 | * in the above tree, if InjectorE requests a service, it will navigate up to the RootInjector and cache. 36 | * If Inject B then requests the same token, it will recieve the same cached instance from RootInjector. 37 | */ 38 | export class Injector { 39 | // keep track of instances. One Token can have one instance 40 | #instances = new WeakMap, any>(); 41 | 42 | name?: string; 43 | parent?: Injector; 44 | providers: ProviderMap; 45 | 46 | constructor(opts?: InjectorOpts) { 47 | this.parent = opts?.parent; 48 | this.providers = new ProviderMap(opts?.providers); 49 | } 50 | 51 | injectAll(token: InjectionToken, collection: T[] = []): T[] { 52 | collection.push(this.inject(token, { skipParent: true })); 53 | 54 | if (this.parent) { 55 | return this.parent.injectAll(token, collection); 56 | } 57 | 58 | return collection; 59 | } 60 | 61 | // resolves and retuns and instance of the requested service 62 | inject(token: InjectionToken, opts?: { skipParent: boolean }): T { 63 | // check for a local instance 64 | if (this.#instances.has(token)) { 65 | const instance = this.#instances.get(token); 66 | 67 | const metadata = readMetadata(token); 68 | const injector = readInjector(instance); 69 | 70 | if (metadata) { 71 | callLifecycle(instance, injector ?? this, metadata.onInjected); 72 | } 73 | 74 | return instance; 75 | } 76 | 77 | const provider = this.providers.get(token); 78 | 79 | // check for a provider definition 80 | if (provider) { 81 | if ("use" in provider) { 82 | return this.#createAndCache(token, () => new provider.use()); 83 | } 84 | 85 | if ("factory" in provider) { 86 | return this.#createAndCache(token, provider.factory); 87 | } 88 | 89 | throw new Error( 90 | `Provider for ${token.name} found but is missing either 'use' or 'factory'`, 91 | ); 92 | } 93 | 94 | // check for a parent and attempt to get there 95 | if (this.parent && !opts?.skipParent) { 96 | return this.parent.inject(token); 97 | } 98 | 99 | if (token instanceof StaticToken) { 100 | if (!token.factory) { 101 | throw new Error(`Provider not found for "${token.name}"`); 102 | } 103 | 104 | return this.#createAndCache(token, token.factory); 105 | } 106 | 107 | return this.#createAndCache(token, () => new token()); 108 | } 109 | 110 | clear(): void { 111 | this.#instances = new WeakMap(); 112 | } 113 | 114 | #createAndCache(token: InjectionToken, factory: ProviderFactory): T { 115 | const instance = factory(this); 116 | 117 | this.#instances.set(token, instance); 118 | 119 | /** 120 | * Only values that are objects are able to have associated injectors 121 | */ 122 | const injector = readInjector(instance); 123 | 124 | if (!injector) { 125 | return instance; 126 | } 127 | 128 | if (injector !== this) { 129 | /** 130 | * set the this injector instance as a parent. 131 | * This should ONLY happen in the injector is not self. This would cause an infinite loop. 132 | * this means that each calling injector will be the parent of what it creates. 133 | * this allows the created service to navigate up it's chain to find a root 134 | */ 135 | injector.parent = this; 136 | } 137 | 138 | /** 139 | * the onInject and onInit lifecycle hook should be called after the parent is defined. 140 | * this ensures that services are initialized when the chain is settled 141 | * this is required since the parent is set after the instance is constructed 142 | */ 143 | const metadata = readMetadata(token); 144 | 145 | if (metadata) { 146 | callLifecycle(instance ?? this, injector, metadata.onCreated); 147 | callLifecycle(instance ?? this, injector, metadata.onInjected); 148 | } 149 | 150 | return instance; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /packages/di/src/lib/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import type { Injector } from "./injector.js"; 2 | import type { 3 | InjectableMetadata, 4 | LifecycleCallback, 5 | LifecycleCondition, 6 | LifecycleMethod, 7 | } from "./metadata.js"; 8 | 9 | export function injected(condition?: LifecycleCondition) { 10 | return function onInjectDecorator( 11 | val: LifecycleCallback, 12 | ctx: ClassMethodDecoratorContext, 13 | ): void { 14 | const metadata: InjectableMetadata = ctx.metadata; 15 | metadata.onInjected ??= []; 16 | metadata.onInjected.push({ 17 | callback: val, 18 | condition, 19 | }); 20 | }; 21 | } 22 | 23 | export function created(condition?: LifecycleCondition) { 24 | return function onInjectDecorator( 25 | val: LifecycleCallback, 26 | ctx: ClassMethodDecoratorContext, 27 | ): void { 28 | const metadata: InjectableMetadata = ctx.metadata; 29 | metadata.onCreated ??= []; 30 | metadata.onCreated.push({ 31 | callback: val, 32 | condition, 33 | }); 34 | }; 35 | } 36 | 37 | export function callLifecycle( 38 | instance: object, 39 | injector: Injector, 40 | methods?: LifecycleMethod[], 41 | ): void { 42 | if (methods) { 43 | for (const { callback, condition } of methods) { 44 | if (condition) { 45 | const result = condition({ injector, instance }); 46 | if (result.enabled === false) { 47 | continue; 48 | } 49 | } 50 | callback.call(instance, injector); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/di/src/lib/metadata.ts: -------------------------------------------------------------------------------- 1 | (Symbol as any).metadata ??= Symbol("Symbol.metadata"); 2 | 3 | import { INJECTOR, type Injector } from "./injector.js"; 4 | import type { InjectionToken } from "./provider.js"; 5 | 6 | export type LifecycleCallback = (i: Injector) => void; 7 | 8 | export type LifecycleCondition = (ctx:{injector: Injector, instance: T}) => { enabled?: boolean }; 9 | 10 | export interface LifecycleMethod { 11 | callback: LifecycleCallback; 12 | condition?: LifecycleCondition; 13 | } 14 | 15 | export interface InjectableMetadata { 16 | onCreated?: LifecycleMethod[]; 17 | onInjected?: LifecycleMethod[]; 18 | } 19 | 20 | export function readMetadata(target: InjectionToken): InjectableMetadata | null { 21 | const metadata: InjectableMetadata | null = target[Symbol.metadata]; 22 | 23 | return metadata; 24 | } 25 | 26 | export function readInjector(target: T): Injector | null { 27 | if (typeof target === "object" && target !== null) { 28 | if (INJECTOR in target) { 29 | return target[INJECTOR] as Injector; 30 | } 31 | } 32 | 33 | return null; 34 | } 35 | -------------------------------------------------------------------------------- /packages/di/src/lib/provider.ts: -------------------------------------------------------------------------------- 1 | import type { Injector } from "./injector.js"; 2 | 3 | export type ProviderFactory = (injector: Injector) => T; 4 | 5 | export class StaticToken { 6 | #name: string; 7 | #factory?: ProviderFactory; 8 | 9 | [Symbol.metadata] = null; 10 | 11 | get name(): string { 12 | return this.#name; 13 | } 14 | 15 | get factory(): ProviderFactory | undefined { 16 | return this.#factory; 17 | } 18 | 19 | constructor(name: string, factory?: ProviderFactory) { 20 | this.#name = name; 21 | this.#factory = factory; 22 | } 23 | } 24 | 25 | export interface ConstructableToken { 26 | new (...args: any[]): T; 27 | } 28 | 29 | export type InjectionToken = ConstructableToken | StaticToken; 30 | 31 | export type ProviderDef = 32 | | { 33 | use: ConstructableToken; 34 | } 35 | | { 36 | factory: ProviderFactory; 37 | }; 38 | 39 | export type Provider = [InjectionToken, ProviderDef]; 40 | -------------------------------------------------------------------------------- /packages/di/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "target", 6 | "isolatedDeclarations": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/di/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare interface HTMLElement { 2 | connectedCallback?(): void; 3 | disconnectedCallback?(): void; 4 | } 5 | -------------------------------------------------------------------------------- /packages/di/wtr.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | rootDir: "../../", 3 | nodeResolve: { 4 | exportConditions: ["production"], 5 | }, 6 | files: "target/**/*.test.js", 7 | port: 9875, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/element/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2020 Danny Blue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /packages/element/README.md: -------------------------------------------------------------------------------- 1 | # Element 2 | 3 | Utilities for building web compnennts. Especially targeted at 4 | 5 | ## Table of Contents 6 | 7 | - [Installation](#installation) 8 | - [Custom Element](#custom-element) 9 | - [Attributes](#attributes) 10 | - [Styles](#styles) 11 | - [Listeners](#listeners) 12 | - [Queries](#queries) 13 | 14 | ## Installation 15 | 16 | ```BASH 17 | npm i @joist/element 18 | ``` 19 | 20 | ## Custom Element 21 | 22 | To define a custom element decorate your custom element class and add a tagName 23 | 24 | ```ts 25 | @element({ 26 | tagName: 'my-element' 27 | }) 28 | export class MyElement extends HTMLElement {} 29 | ``` 30 | 31 | ## Attributes 32 | 33 | Attributes can be managed using the `@attr` decorator. This decorator will read attribute values and and write properties back to attributes; 34 | 35 | ```ts 36 | @element({ 37 | tagName: 'my-element' 38 | }) 39 | export class MyElement extends HTMLElement { 40 | @attr() 41 | accessor greeting = 'Hello World'; 42 | } 43 | ``` 44 | 45 | ## HTML and CSS 46 | 47 | HTML templates can be applied by passing the result of the `html` tag to the shaodw list. 48 | CSS can be applied by passing the result of the `css` tag to the shadow list. 49 | Any new tagged template literal that returns a `ShadowResult` can be used. 50 | 51 | ```ts 52 | @element({ 53 | tagName: 'my-element', 54 | shadowDom: [ 55 | css` 56 | h1 { 57 | color: red; 58 | } 59 | `, 60 | html`

Hello World

` 61 | ] 62 | }) 63 | export class MyElement extends HTMLElement {} 64 | ``` 65 | 66 | ## Listeners 67 | 68 | The `@listen` decorator allows you to easy setup event listeners. By default the listener will be attached to the shadow root if it exists or the host element if it doesn't. This can be customized by pass a selector function to the decorator 69 | 70 | ```ts 71 | @element({ 72 | tagName: 'my-element', 73 | shadowDom: [] 74 | }) 75 | export class MyElement extends HTMLElement { 76 | @listen('eventname') 77 | onEventName1() { 78 | // all listener to the shadow root 79 | } 80 | 81 | @listen('eventname', (host) => host) 82 | onEventName2() { 83 | // all listener to the host element 84 | } 85 | 86 | @listen('eventname', (host) => host.querySelector('button')) 87 | onEventName3() { 88 | // add listener to a button found in the light dom 89 | } 90 | 91 | @listen('eventname', '#test') 92 | onEventName4() { 93 | // add listener to element with the id of "test" that is found in the shadow dom 94 | } 95 | } 96 | ``` 97 | 98 | ## Query 99 | 100 | The `query` function will query for a particular element and allow you to easily patch that element with new properties. 101 | 102 | ```ts 103 | @element({ 104 | tagName: 'my-element', 105 | shadowDom: [ 106 | html` 107 | 110 | 111 | 112 | ` 113 | ] 114 | }) 115 | export class MyElement extends HTMLElement { 116 | @observe() 117 | accessor value: string; 118 | 119 | #input = query('input'); 120 | 121 | @effect() 122 | onChange() { 123 | const input = this.#input({ value: this.value}); 124 | } 125 | } 126 | ``` 127 | 128 | ## QueryAll 129 | 130 | The `queryAll` function will get all elements that match the given query. A patching function can be passed to update any or all items in the list 131 | 132 | ```ts 133 | @element({ 134 | tagName: 'my-element', 135 | shadowDom: [ 136 | html` 137 | 138 | 139 | ` 140 | ] 141 | }) 142 | export class MyElement extends HTMLElement { 143 | @observe() 144 | accessor value: string; 145 | 146 | #inputs = queryAll('input'); 147 | 148 | @effect() 149 | onChange() { 150 | this.#input(() => { 151 | return { value: this.value } 152 | }) 153 | } 154 | } 155 | ``` 156 | -------------------------------------------------------------------------------- /packages/element/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joist/element", 3 | "version": "4.2.4-next.7", 4 | "type": "module", 5 | "main": "./target/lib.js", 6 | "module": "./target/lib.js", 7 | "exports": { 8 | ".": "./target/lib.js", 9 | "./*": "./target/lib/*", 10 | "./package.json": "./package.json" 11 | }, 12 | "files": [ 13 | "src", 14 | "target" 15 | ], 16 | "sideEffects": [ 17 | "**/define.js" 18 | ], 19 | "description": "Intelligently apply styles to WebComponents", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/joist-framework/joist.git" 23 | }, 24 | "keywords": [ 25 | "TypeScript", 26 | "WebComponents", 27 | "CSS", 28 | "ShadowDOM" 29 | ], 30 | "author": "deebloo", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/joist-framework/joist/issues" 34 | }, 35 | "publishConfig": { 36 | "access": "public" 37 | }, 38 | "scripts": { 39 | "test": "wireit", 40 | "build": "wireit" 41 | }, 42 | "wireit": { 43 | "build": { 44 | "command": "tsc --build --pretty", 45 | "clean": "if-file-deleted", 46 | "files": [ 47 | "src/**", 48 | "tsconfig.json", 49 | "../../tsconfig.json" 50 | ], 51 | "output": [ 52 | "target/**", 53 | "tsconfig.tsbuildinfo" 54 | ], 55 | "dependencies": [ 56 | "../observable:build" 57 | ] 58 | }, 59 | "test": { 60 | "command": "wtr --config wtr.config.mjs", 61 | "files": [ 62 | "vitest.config.js", 63 | "target/**" 64 | ], 65 | "output": [], 66 | "dependencies": [ 67 | "build" 68 | ] 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/element/src/lib.ts: -------------------------------------------------------------------------------- 1 | export { css, html, HTMLResult, CSSResult } from "./lib/tags.js"; 2 | export { attr } from "./lib/attr.js"; 3 | export { listen } from "./lib/listen.js"; 4 | export { element } from "./lib/element.js"; 5 | export { query } from "./lib/query.js"; 6 | export { queryAll } from "./lib/query-all.js"; 7 | export { QueryResult } from "./lib/query.js"; 8 | export { ready } from "./lib/lifecycle.js"; 9 | export { attrChanged } from "./lib/attr-changed.js"; 10 | -------------------------------------------------------------------------------- /packages/element/src/lib/attr-changed.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { attrChanged } from "./attr-changed.js"; 4 | import { attr } from "./attr.js"; 5 | import { element } from "./element.js"; 6 | 7 | it("should call specific attrbute callback", () => { 8 | let args: string[] = []; 9 | 10 | @element({ 11 | tagName: "attr-changed-1", 12 | }) 13 | class MyElement extends HTMLElement { 14 | @attr() 15 | accessor test = "hello"; 16 | 17 | @attrChanged("test") 18 | onTestChanged(name: string, oldValue: string, newValue: string) { 19 | args = [name, oldValue, newValue]; 20 | } 21 | } 22 | 23 | const el = new MyElement(); 24 | 25 | document.body.append(el); 26 | 27 | assert.deepEqual(args, ["test", null, "hello"]); 28 | 29 | el.setAttribute("test", "world"); 30 | 31 | assert.deepEqual(args, ["test", "hello", "world"]); 32 | 33 | el.remove(); 34 | }); 35 | 36 | it("should call callback for multiple attributes", () => { 37 | const args: string[][] = []; 38 | 39 | @element({ 40 | tagName: "attr-changed-2", 41 | }) 42 | class MyElement extends HTMLElement { 43 | @attr() 44 | accessor test1 = "hello"; 45 | 46 | @attr() 47 | accessor test2 = "world"; 48 | 49 | @attrChanged("test1", "test2") 50 | onTestChanged(attr: string, oldValue: string, newValue: string) { 51 | args.push([attr, oldValue, newValue]); 52 | } 53 | } 54 | 55 | const el = new MyElement(); 56 | 57 | document.body.append(el); 58 | 59 | assert.deepEqual(args, [ 60 | ["test1", null, "hello"], 61 | ["test2", null, "world"], 62 | ]); 63 | 64 | el.setAttribute("test1", "world"); 65 | 66 | assert.deepEqual(args, [ 67 | ["test1", null, "hello"], 68 | ["test2", null, "world"], 69 | ["test1", "hello", "world"], 70 | ]); 71 | 72 | el.remove(); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/element/src/lib/attr-changed.ts: -------------------------------------------------------------------------------- 1 | import { type AttrChangedCallback, metadataStore } from "./metadata.js"; 2 | 3 | export function attrChanged(...names: string[]) { 4 | return function attrChangedDecorator( 5 | cb: AttrChangedCallback, 6 | ctx: ClassMethodDecoratorContext, 7 | ): void { 8 | const meta = metadataStore.read(ctx.metadata); 9 | 10 | for (const name of names) { 11 | const val = meta.attrChanges.get(name) ?? new Set(); 12 | 13 | val.add(cb); 14 | 15 | meta.attrChanges.set(name, val); 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/element/src/lib/attr.ts: -------------------------------------------------------------------------------- 1 | import { metadataStore } from "./metadata.js"; 2 | 3 | export interface AttrOpts { 4 | name?: string; 5 | observed?: boolean; 6 | reflect?: boolean; 7 | } 8 | 9 | export function attr(opts?: AttrOpts) { 10 | return function attrDecorator( 11 | base: ClassAccessorDecoratorTarget, 12 | ctx: ClassAccessorDecoratorContext, 13 | ): ClassAccessorDecoratorResult { 14 | const attrName = opts?.name ?? parseAttrName(ctx.name); 15 | const meta = metadataStore.read(ctx.metadata); 16 | const reflect = opts?.reflect ?? true; 17 | 18 | meta.attrs.set(attrName, { 19 | propName: ctx.name, 20 | observe: opts?.observed ?? true, 21 | reflect, 22 | access: base, 23 | }); 24 | 25 | return { 26 | init(value: unknown) { 27 | if (typeof value === "boolean") { 28 | return value; 29 | } 30 | 31 | const attrValue = this.getAttribute(attrName); 32 | 33 | if (attrValue === null) { 34 | return value; 35 | } 36 | 37 | if (typeof value === "number") { 38 | return Number(attrValue); 39 | } 40 | 41 | return attrValue; 42 | }, 43 | set(value: unknown) { 44 | if (reflect) { 45 | if (value === true) { 46 | if (!this.hasAttribute(attrName)) { 47 | this.setAttribute(attrName, ""); 48 | } 49 | } else if (value === false) { 50 | if (this.hasAttribute(attrName)) { 51 | this.removeAttribute(attrName); 52 | } 53 | } else { 54 | const strValue = String(value); 55 | 56 | if (this.getAttribute(attrName) !== strValue) { 57 | this.setAttribute(attrName, strValue); 58 | } 59 | } 60 | } 61 | 62 | base.set.call(this, value); 63 | }, 64 | }; 65 | }; 66 | } 67 | 68 | function parseAttrName(val: string | symbol): string { 69 | let value: string; 70 | 71 | if (typeof val === "symbol") { 72 | if (val.description) { 73 | value = val.description; 74 | } else { 75 | throw new Error("Cannot handle Symbol property without description"); 76 | } 77 | } else { 78 | value = val; 79 | } 80 | 81 | return value.toLowerCase().replaceAll(" ", "-"); 82 | } 83 | -------------------------------------------------------------------------------- /packages/element/src/lib/element.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from "chai"; 2 | 3 | import { attr } from "./attr.js"; 4 | import { element } from "./element.js"; 5 | import { css, html } from "./tags.js"; 6 | 7 | it("should write default value to attribute", async () => { 8 | @element({ 9 | tagName: "element-1", 10 | }) 11 | class MyElement extends HTMLElement { 12 | @attr() 13 | accessor value1 = "hello"; // no attribute 14 | 15 | @attr() 16 | accessor value2 = 0; // number 17 | 18 | @attr() 19 | accessor value3 = true; // boolean 20 | 21 | @attr({ reflect: false }) 22 | accessor value4 = "foo"; 23 | } 24 | 25 | const el = new MyElement(); 26 | 27 | document.body.append(el); 28 | 29 | expect(el.getAttribute("value1")).to.equal("hello"); 30 | expect(el.getAttribute("value2")).to.equal("0"); 31 | expect(el.getAttribute("value3")).to.equal(""); 32 | expect(el.getAttribute("value4")).to.equal(null); 33 | 34 | el.remove(); 35 | }); 36 | 37 | it("should register attributes", async () => { 38 | const observedAttrs: string[] = []; 39 | 40 | @element({ 41 | tagName: "element-2", 42 | }) 43 | class MyElement extends HTMLElement { 44 | @attr() 45 | accessor value1 = "hello"; 46 | 47 | @attr() 48 | accessor value2 = 0; 49 | 50 | @attr() 51 | accessor value3 = true; 52 | 53 | @attr({ observed: false }) 54 | accessor value4 = "hello world"; 55 | 56 | attributeChangedCallback(name: string) { 57 | observedAttrs.push(name); 58 | } 59 | } 60 | 61 | const el = new MyElement(); 62 | 63 | el.setAttribute("value1", "foo"); 64 | el.setAttribute("value2", "1"); 65 | el.setAttribute("value3", "false"); 66 | el.setAttribute("value4", "bar"); 67 | 68 | expect(observedAttrs).to.deep.equal(["value1", "value2", "value3"]); 69 | }); 70 | 71 | it("should attach shadow root when the shadow property exists", async () => { 72 | @element({ 73 | tagName: "element-3", 74 | shadowDom: [], 75 | }) 76 | class MyElement extends HTMLElement {} 77 | 78 | const el = new MyElement(); 79 | 80 | expect(el.shadowRoot).to.be.instanceOf(ShadowRoot); 81 | }); 82 | 83 | it("should apply html and css", async () => { 84 | @element({ 85 | tagName: "element-4", 86 | shadowDom: [ 87 | css` 88 | :host { 89 | display: contents; 90 | } 91 | `, 92 | html``, 93 | { 94 | apply(el) { 95 | const div = document.createElement("div"); 96 | div.innerHTML = "hello world"; 97 | 98 | el.append(div); 99 | }, 100 | }, 101 | ], 102 | }) 103 | class MyElement extends HTMLElement {} 104 | 105 | const el = new MyElement(); 106 | 107 | expect(el.shadowRoot?.adoptedStyleSheets.length).to.equal(1); 108 | expect(el.shadowRoot?.innerHTML).to.equal(""); 109 | expect(el.innerHTML).to.equal("
hello world
"); 110 | }); 111 | 112 | it("should the correct shadow dom mode", async () => { 113 | @element({ 114 | tagName: "element-5", 115 | shadowDom: [], 116 | shadowDomOpts: { 117 | mode: "closed", 118 | }, 119 | }) 120 | class MyElement extends HTMLElement {} 121 | 122 | const el = new MyElement(); 123 | 124 | assert.equal(el.shadowRoot, null); 125 | }); 126 | -------------------------------------------------------------------------------- /packages/element/src/lib/element.ts: -------------------------------------------------------------------------------- 1 | import { type AttrMetadata, metadataStore } from "./metadata.js"; 2 | import type { ShadowResult } from "./result.js"; 3 | 4 | export interface ElementOpts { 5 | tagName?: string; 6 | shadowDom?: ShadowResult[]; 7 | shadowDomOpts?: ShadowRootInit; 8 | } 9 | 10 | interface ElementConstructor { 11 | new (...args: any[]): HTMLElement; 12 | } 13 | 14 | export function element(opts?: ElementOpts) { 15 | return function elementDecorator(Base: T, ctx: ClassDecoratorContext): T { 16 | const meta = metadataStore.read(ctx.metadata); 17 | 18 | ctx.addInitializer(function () { 19 | if (opts?.tagName) { 20 | if (!customElements.get(opts.tagName)) { 21 | customElements.define(opts.tagName, this); 22 | } 23 | } 24 | }); 25 | 26 | const def = { 27 | [Base.name]: class extends Base { 28 | static observedAttributes: string[] = Array.from(meta.attrs.keys()); 29 | 30 | #abortController: AbortController | null = null; 31 | 32 | constructor(...args: any[]) { 33 | super(...args); 34 | 35 | if (opts?.shadowDom) { 36 | if (!this.shadowRoot) { 37 | this.attachShadow(opts.shadowDomOpts ?? { mode: "open" }); 38 | } 39 | 40 | for (const res of opts.shadowDom) { 41 | res.apply(this); 42 | } 43 | } 44 | 45 | for (const cb of meta.onReady) { 46 | cb.call(this); 47 | } 48 | } 49 | 50 | attributeChangedCallback(name: string, oldValue: string, newValue: string) { 51 | const attr = meta.attrs.get(name); 52 | const cbs = meta.attrChanges.get(name); 53 | 54 | if (attr) { 55 | if (oldValue !== newValue) { 56 | const sourceValue = attr.access.get.call(this); 57 | let value: string | number | boolean = newValue; 58 | 59 | if (typeof sourceValue === "boolean") { 60 | // treat as boolean 61 | value = newValue !== null; 62 | } else if (typeof sourceValue === "number") { 63 | // treat as number 64 | value = Number(newValue); 65 | } 66 | 67 | attr.access.set.call(this, value); 68 | } 69 | 70 | if (cbs) { 71 | for (const cb of cbs) { 72 | cb.call(this, name, oldValue, newValue); 73 | } 74 | } 75 | 76 | if (attr.observe) { 77 | if (super.attributeChangedCallback) { 78 | super.attributeChangedCallback(name, oldValue, newValue); 79 | } 80 | } 81 | } 82 | } 83 | 84 | connectedCallback() { 85 | if (!this.#abortController) { 86 | this.#abortController = new AbortController(); 87 | 88 | for (const { event, cb, selector } of meta.listeners) { 89 | const root = selector(this); 90 | 91 | if (root) { 92 | root.addEventListener(event, cb.bind(this), { 93 | signal: this.#abortController.signal, 94 | }); 95 | } else { 96 | throw new Error(`could not add listener to ${root}`); 97 | } 98 | } 99 | } 100 | 101 | reflectAttributeValues(this, meta.attrs); 102 | 103 | if (super.connectedCallback) { 104 | super.connectedCallback(); 105 | } 106 | } 107 | 108 | disconnectedCallback(): void { 109 | if (this.#abortController) { 110 | this.#abortController.abort(); 111 | this.#abortController = null; 112 | } 113 | 114 | if (super.disconnectedCallback) { 115 | super.disconnectedCallback(); 116 | } 117 | } 118 | }, 119 | }; 120 | 121 | return def[Base.name]; 122 | }; 123 | } 124 | 125 | function reflectAttributeValues(el: T, attrs: AttrMetadata) { 126 | for (const [attrName, { access, reflect }] of attrs) { 127 | if (reflect) { 128 | const value = access.get.call(el); 129 | 130 | // reflect values back to attributes 131 | if (value !== null && value !== undefined && value !== "") { 132 | if (typeof value === "boolean") { 133 | if (value === true) { 134 | // set boolean attribute 135 | if (!el.hasAttribute(attrName)) { 136 | el.setAttribute(attrName, ""); 137 | } 138 | } 139 | } else if (!el.hasAttribute(attrName)) { 140 | // only set parent attribute if it doesn't exist 141 | // set key/value attribute 142 | const strValue = String(value); 143 | 144 | if (el.getAttribute(attrName) !== strValue) { 145 | el.setAttribute(attrName, strValue); 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /packages/element/src/lib/lifecycle.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { element } from "./element.js"; 3 | import { ready } from "./lifecycle.js"; 4 | 5 | it("should call all callbacks when template is ready", () => { 6 | @element({ 7 | tagName: "template-ready-1", 8 | }) 9 | class MyElement extends HTMLElement { 10 | callCount: Record = {}; 11 | 12 | @ready() 13 | onTemplateReady1() { 14 | this.callCount.onTemplateReady1 ??= 0; 15 | this.callCount.onTemplateReady1++; 16 | } 17 | 18 | @ready() 19 | onTemplateReady2() { 20 | this.callCount.onTemplateReady2 ??= 0; 21 | this.callCount.onTemplateReady2++; 22 | } 23 | } 24 | 25 | const el = new MyElement(); 26 | 27 | assert.deepEqual(el.callCount, { 28 | onTemplateReady1: 1, 29 | onTemplateReady2: 1, 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/element/src/lib/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import { metadataStore } from "./metadata.js"; 2 | 3 | export function ready() { 4 | return function readyDecorator( 5 | val: () => void, 6 | ctx: ClassMethodDecoratorContext, 7 | ): void { 8 | const metadata = metadataStore.read(ctx.metadata); 9 | 10 | metadata.onReady.add(val); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /packages/element/src/lib/listen.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { element } from "./element.js"; 4 | import { listen } from "./listen.js"; 5 | 6 | describe("@listen()", () => { 7 | it("should add listener to an outer HTMLElement", (done) => { 8 | @element({ 9 | tagName: "listener-1", 10 | }) 11 | class MyElement extends HTMLElement { 12 | @listen("click") 13 | onClick(e: Event) { 14 | assert.equal(e.type, "click"); 15 | 16 | done(); 17 | } 18 | } 19 | 20 | const el = new MyElement(); 21 | 22 | document.body.append(el); 23 | 24 | el.dispatchEvent(new Event("click")); 25 | 26 | el.remove(); 27 | }); 28 | 29 | it("should add listener to the shadow root if available", (done) => { 30 | @element({ 31 | tagName: "listener-2", 32 | shadowDom: [], 33 | }) 34 | class MyElement extends HTMLElement { 35 | @listen("click") 36 | onClick(e: Event) { 37 | assert.equal(e.type, "click"); 38 | 39 | done(); 40 | } 41 | } 42 | 43 | const el = new MyElement(); 44 | 45 | document.body.append(el); 46 | 47 | el.shadowRoot?.dispatchEvent(new Event("click")); 48 | 49 | el.remove(); 50 | }); 51 | 52 | it("should restrict argument to an event or an event subtype", (done) => { 53 | class CustomEvent extends Event { 54 | test = "Hello World"; 55 | 56 | constructor() { 57 | super("customevent"); 58 | } 59 | } 60 | 61 | @element({ 62 | tagName: "listener-3", 63 | }) 64 | class MyElement extends HTMLElement { 65 | @listen("customevent") 66 | onClick(e: CustomEvent) { 67 | assert.equal(e.type, "customevent"); 68 | 69 | done(); 70 | } 71 | } 72 | 73 | const el = new MyElement(); 74 | 75 | document.body.append(el); 76 | 77 | el.dispatchEvent(new CustomEvent()); 78 | 79 | el.remove(); 80 | }); 81 | 82 | it("should respect a provided selector function", (done) => { 83 | @element({ 84 | tagName: "listener-4", 85 | shadowDom: [], 86 | }) 87 | class MyElement extends HTMLElement { 88 | @listen("click", (host) => host) 89 | onClick(e: Event) { 90 | assert.equal(e.type, "click"); 91 | 92 | done(); 93 | } 94 | } 95 | 96 | const el = new MyElement(); 97 | 98 | document.body.append(el); 99 | 100 | el.dispatchEvent(new Event("click")); 101 | 102 | el.remove(); 103 | }); 104 | }); 105 | 106 | it("should remove event listeners during cleanup", () => { 107 | let clickCount = 0; 108 | 109 | @element({ 110 | tagName: "listener-cleanup", 111 | shadowDom: [], 112 | }) 113 | class MyElement extends HTMLElement { 114 | @listen("click") 115 | onClick1() { 116 | clickCount++; 117 | } 118 | 119 | @listen("click") 120 | onClick2() { 121 | clickCount++; 122 | } 123 | } 124 | 125 | const el = new MyElement(); 126 | document.body.append(el); 127 | 128 | // First click should increment counter 129 | el.shadowRoot?.dispatchEvent(new Event("click")); 130 | assert.equal(clickCount, 2); 131 | 132 | // Remove element which should cleanup listeners 133 | el.remove(); 134 | 135 | // Second click after removal should not increment counter 136 | el.shadowRoot?.dispatchEvent(new Event("click")); 137 | assert.equal(clickCount, 2); 138 | }); 139 | 140 | it("should not add event listeners multiple times when element is moved", () => { 141 | let clickCount = 0; 142 | 143 | @element({ 144 | tagName: "listener-move", 145 | shadowDom: [], 146 | }) 147 | class MyElement extends HTMLElement { 148 | @listen("click") 149 | onClick() { 150 | clickCount++; 151 | } 152 | } 153 | 154 | const el = new MyElement(); 155 | const container1 = document.createElement("div"); 156 | const container2 = document.createElement("div"); 157 | 158 | document.body.append(container1); 159 | document.body.append(container2); 160 | 161 | // Add to first container 162 | container1.append(el); 163 | 164 | // Click should increment once 165 | el.shadowRoot?.dispatchEvent(new Event("click")); 166 | assert.equal(clickCount, 1); 167 | 168 | // Move to second container 169 | container2.append(el); 170 | 171 | // Click should still only increment once 172 | el.shadowRoot?.dispatchEvent(new Event("click")); 173 | assert.equal(clickCount, 2); 174 | 175 | // Cleanup 176 | el.remove(); 177 | container1.remove(); 178 | container2.remove(); 179 | }); 180 | -------------------------------------------------------------------------------- /packages/element/src/lib/listen.ts: -------------------------------------------------------------------------------- 1 | import { type ListenerSelector, metadataStore } from "./metadata.js"; 2 | 3 | export function listen( 4 | event: string, 5 | selector?: ListenerSelector | string, 6 | ) { 7 | return function listenDecorator( 8 | value: (e: any) => void, 9 | ctx: ClassMethodDecoratorContext, 10 | ): void { 11 | const metadata = metadataStore.read(ctx.metadata); 12 | 13 | let selectorInternal: ListenerSelector = (el) => el.shadowRoot ?? el; 14 | 15 | if (selector) { 16 | if (typeof selector === "string") { 17 | selectorInternal = (el: This) => { 18 | if (el.shadowRoot) { 19 | return el.shadowRoot.querySelector(selector); 20 | } 21 | 22 | return el.querySelector(selector); 23 | }; 24 | } else { 25 | selectorInternal = selector; 26 | } 27 | } 28 | 29 | metadata.listeners.push({ 30 | event, 31 | cb: value, 32 | selector: selectorInternal, 33 | }); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /packages/element/src/lib/metadata.ts: -------------------------------------------------------------------------------- 1 | (Symbol as any).metadata ??= Symbol("Symbol.metadata"); 2 | 3 | export interface AttrDef { 4 | propName: string | symbol; 5 | observe: boolean; 6 | reflect: boolean; 7 | access: { 8 | get: () => unknown; 9 | set: (value: unknown) => void; 10 | }; 11 | } 12 | 13 | export type ListenerSelector = (el: T) => EventTarget | null; 14 | 15 | export interface Listener { 16 | event: string; 17 | cb: (e: Event) => void; 18 | selector: ListenerSelector; 19 | } 20 | 21 | export type AttrChangedCallback = (name: string, oldValue: string, newValue: string) => void; 22 | 23 | export class AttrMetadata extends Map {} 24 | export class AttrChangeMetadata extends Map> {} 25 | 26 | export class ElementMetadata { 27 | attrs: AttrMetadata = new AttrMetadata(); 28 | attrChanges: AttrChangeMetadata = new AttrChangeMetadata(); 29 | listeners: Listener[] = []; 30 | onReady: Set<() => void> = new Set(); 31 | } 32 | 33 | export class MetadataStore extends WeakMap> { 34 | read(value: object): ElementMetadata { 35 | if (!this.has(value)) { 36 | this.set(value, new ElementMetadata()); 37 | } 38 | 39 | return this.get(value) as ElementMetadata; 40 | } 41 | } 42 | 43 | export const metadataStore: MetadataStore = new MetadataStore(); 44 | -------------------------------------------------------------------------------- /packages/element/src/lib/query-all.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import { element } from "./element.js"; 4 | import { queryAll } from "./query-all.js"; 5 | import { html } from "./tags.js"; 6 | 7 | it("should work", () => { 8 | @element({ 9 | tagName: "query-test-1", 10 | shadowDom: [ 11 | html` 12 |
13 | 14 | 15 |
16 | `, 17 | ], 18 | }) 19 | class MyElement extends HTMLElement { 20 | inputs = queryAll("input"); 21 | } 22 | 23 | const el = new MyElement(); 24 | 25 | expect(el.inputs()[0]).to.equal(el.shadowRoot?.querySelector("#fname")); 26 | expect(el.inputs()[1]).to.equal(el.shadowRoot?.querySelector("#lname")); 27 | }); 28 | 29 | it("should patch items when patch is returned", () => { 30 | @element({ 31 | tagName: "query-test-2", 32 | shadowDom: [ 33 | html` 34 |
35 | 36 | 37 |
38 | `, 39 | ], 40 | }) 41 | class MyElement extends HTMLElement { 42 | inputs = queryAll("input"); 43 | } 44 | 45 | const el = new MyElement(); 46 | 47 | el.inputs((node) => { 48 | if (node.id === "fname") { 49 | return { 50 | value: "Foo", 51 | }; 52 | } 53 | 54 | return null; 55 | }); 56 | 57 | expect( 58 | el.shadowRoot?.querySelector("#fname")?.value, 59 | ).to.equal("Foo"); 60 | 61 | expect( 62 | el.shadowRoot?.querySelector("#lname")?.value, 63 | ).to.equal("Blue"); 64 | }); 65 | 66 | it("should patch the selected item when cached", () => { 67 | @element({ 68 | tagName: "query-test-3", 69 | shadowDom: [ 70 | html` 71 |
72 | 73 | 74 |
75 | `, 76 | ], 77 | }) 78 | class MyElement extends HTMLElement { 79 | inputs = queryAll("input"); 80 | } 81 | 82 | const el = new MyElement(); 83 | el.inputs(); 84 | 85 | el.inputs((node) => { 86 | if (node.id === "fname") { 87 | return { 88 | value: "Foo", 89 | }; 90 | } 91 | 92 | return { 93 | value: "Bar", 94 | }; 95 | }); 96 | 97 | expect( 98 | el.shadowRoot?.querySelector("#fname")?.value, 99 | ).to.equal("Foo"); 100 | 101 | expect( 102 | el.shadowRoot?.querySelector("#lname")?.value, 103 | ).to.equal("Bar"); 104 | }); 105 | 106 | it("should apply the same patch to all elements", () => { 107 | @element({ 108 | tagName: "query-test-4", 109 | shadowDom: [ 110 | html` 111 |
112 | 113 | 114 |
115 | `, 116 | ], 117 | }) 118 | class MyElement extends HTMLElement { 119 | inputs = queryAll("input"); 120 | } 121 | 122 | const el = new MyElement(); 123 | el.inputs({ value: "TEST" }); 124 | 125 | expect( 126 | el.shadowRoot?.querySelector("#fname")?.value, 127 | ).to.equal("TEST"); 128 | 129 | expect( 130 | el.shadowRoot?.querySelector("#lname")?.value, 131 | ).to.equal("TEST"); 132 | }); 133 | 134 | it("should use passed in root", () => { 135 | @element({ 136 | tagName: "query-test-5", 137 | shadowDom: [], 138 | }) 139 | class MyElement extends HTMLElement { 140 | inputs = queryAll("input", this); 141 | } 142 | 143 | const el = new MyElement(); 144 | el.innerHTML = /*html*/ ` 145 |
146 | 147 | 148 |
149 | `; 150 | 151 | expect(el.inputs()[0]).to.equal(el.querySelector("#fname")); 152 | expect(el.inputs()[1]).to.equal(el.querySelector("#lname")); 153 | }); 154 | -------------------------------------------------------------------------------- /packages/element/src/lib/query-all.ts: -------------------------------------------------------------------------------- 1 | type Tags = keyof HTMLElementTagNameMap; 2 | type SVGTags = keyof SVGElementTagNameMap; 3 | type MathTags = keyof MathMLElementTagNameMap; 4 | 5 | type NodeUpdate = Partial | ((node: T) => Partial | null); 6 | 7 | type QueryAllResult = ( 8 | updates?: NodeUpdate, 9 | ) => NodeListOf; 10 | 11 | export function queryAll( 12 | selectors: K, 13 | root?: HTMLElement | ShadowRoot, 14 | ): QueryAllResult; 15 | export function queryAll( 16 | selectors: K, 17 | root?: HTMLElement | ShadowRoot, 18 | ): QueryAllResult; 19 | export function queryAll( 20 | selectors: K, 21 | root?: HTMLElement | ShadowRoot, 22 | ): QueryAllResult; 23 | export function queryAll( 24 | selectors: string, 25 | root?: HTMLElement | ShadowRoot, 26 | ): QueryAllResult; 27 | export function queryAll( 28 | query: K, 29 | root?: HTMLElement | ShadowRoot, 30 | ): QueryAllResult { 31 | let res: NodeListOf | null = null; 32 | 33 | return function ( 34 | this: HTMLElementTagNameMap[K], 35 | update?: NodeUpdate, 36 | ) { 37 | if (res) { 38 | return patchNodes(res, update); 39 | } 40 | 41 | if (root) { 42 | res = root.querySelectorAll(query); 43 | } else if (this.shadowRoot) { 44 | res = this.shadowRoot.querySelectorAll(query); 45 | } else { 46 | res = this.querySelectorAll(query); 47 | } 48 | 49 | if (!res) { 50 | throw new Error(`could not find ${query}`); 51 | } 52 | 53 | return patchNodes(res, update); 54 | }; 55 | } 56 | 57 | function patchNodes( 58 | target: NodeListOf, 59 | update?: NodeUpdate, 60 | ): NodeListOf { 61 | if (!update) { 62 | return target; 63 | } 64 | 65 | for (const node of target) { 66 | const patch = typeof update === "function" ? update(node) : update; 67 | 68 | if (patch) { 69 | for (const update in patch) { 70 | const newValue = patch[update]; 71 | const oldValue = node[update]; 72 | 73 | if (newValue !== oldValue) { 74 | Reflect.set(node, update, newValue); 75 | } 76 | } 77 | } 78 | } 79 | 80 | return target; 81 | } 82 | -------------------------------------------------------------------------------- /packages/element/src/lib/query.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import { element } from "./element.js"; 4 | import { query } from "./query.js"; 5 | import { html } from "./tags.js"; 6 | 7 | it("should work", () => { 8 | @element({ 9 | tagName: "query-test-1", 10 | shadowDom: [ 11 | html` 12 |
13 | 14 | 15 |
16 | `, 17 | ], 18 | }) 19 | class MyElement extends HTMLElement { 20 | fname = query("#fname"); 21 | lname = query("#lname"); 22 | } 23 | 24 | const el = new MyElement(); 25 | 26 | expect(el.fname()).to.equal(el.shadowRoot?.querySelector("#fname")); 27 | expect(el.lname()).to.equal(el.shadowRoot?.querySelector("#lname")); 28 | }); 29 | 30 | it("should patch the selected item", () => { 31 | @element({ 32 | tagName: "query-test-2", 33 | shadowDom: [ 34 | html` 35 |
36 | 37 | 38 |
39 | `, 40 | ], 41 | }) 42 | class MyElement extends HTMLElement { 43 | fname = query("#fname"); 44 | lname = query("#lname"); 45 | } 46 | 47 | const el = new MyElement(); 48 | el.fname({ value: "Foo" }); 49 | el.lname({ value: "Bar" }); 50 | 51 | expect( 52 | el.shadowRoot?.querySelector("#fname")?.value, 53 | ).to.equal("Foo"); 54 | 55 | expect( 56 | el.shadowRoot?.querySelector("#lname")?.value, 57 | ).to.equal("Bar"); 58 | }); 59 | 60 | it("should patch the selected item when cached", () => { 61 | @element({ 62 | tagName: "query-test-3", 63 | shadowDom: [ 64 | html` 65 |
66 | 67 | 68 |
69 | `, 70 | ], 71 | }) 72 | class MyElement extends HTMLElement { 73 | fname = query("#fname"); 74 | lname = query("#lname"); 75 | } 76 | 77 | const el = new MyElement(); 78 | el.fname(); 79 | el.lname(); 80 | el.fname({ value: "Foo" }); 81 | el.lname({ value: "Bar" }); 82 | 83 | expect( 84 | el.shadowRoot?.querySelector("#fname")?.value, 85 | ).to.equal("Foo"); 86 | 87 | expect( 88 | el.shadowRoot?.querySelector("#lname")?.value, 89 | ).to.equal("Bar"); 90 | }); 91 | 92 | it("should use function to update", () => { 93 | @element({ 94 | tagName: "query-test-4", 95 | shadowDom: [ 96 | html` 97 |
98 | 99 | 100 |
101 | `, 102 | ], 103 | }) 104 | class MyElement extends HTMLElement { 105 | fname = query("#fname"); 106 | lname = query("#lname"); 107 | } 108 | 109 | const el = new MyElement(); 110 | el.fname(() => ({ value: "Foo" })); 111 | el.lname(() => ({ value: "Bar" })); 112 | 113 | expect( 114 | el.shadowRoot?.querySelector("#fname")?.value, 115 | ).to.equal("Foo"); 116 | 117 | expect( 118 | el.shadowRoot?.querySelector("#lname")?.value, 119 | ).to.equal("Bar"); 120 | }); 121 | 122 | it("should use passed in root", () => { 123 | @element({ 124 | tagName: "query-test-5", 125 | shadowDom: [], 126 | }) 127 | class MyElement extends HTMLElement { 128 | fname = query("#fname", this); 129 | lname = query("#lname", this); 130 | } 131 | 132 | const el = new MyElement(); 133 | el.innerHTML = /*html*/ ` 134 |
135 | 136 | 137 |
138 | `; 139 | 140 | expect(el.fname()).to.equal(el.querySelector("#fname")); 141 | expect(el.lname()).to.equal(el.querySelector("#lname")); 142 | }); 143 | -------------------------------------------------------------------------------- /packages/element/src/lib/query.ts: -------------------------------------------------------------------------------- 1 | type Tags = keyof HTMLElementTagNameMap; 2 | type SVGTags = keyof SVGElementTagNameMap; 3 | type MathTags = keyof MathMLElementTagNameMap; 4 | 5 | type NodeUpdate = Partial | ((node: T) => Partial); 6 | 7 | export type QueryResult = (updates?: NodeUpdate) => T; 8 | 9 | export function query( 10 | selectors: K, 11 | root?: HTMLElement | ShadowRoot, 12 | ): QueryResult; 13 | export function query( 14 | selectors: K, 15 | root?: HTMLElement | ShadowRoot, 16 | ): QueryResult; 17 | export function query( 18 | selectors: K, 19 | root?: HTMLElement | ShadowRoot, 20 | ): QueryResult; 21 | export function query( 22 | selectors: string, 23 | root?: HTMLElement | ShadowRoot, 24 | ): QueryResult; 25 | export function query( 26 | query: K, 27 | root?: HTMLElement | ShadowRoot, 28 | ): QueryResult { 29 | let res: HTMLElementTagNameMap[K] | null = null; 30 | 31 | return function (this: HTMLElementTagNameMap[K], updates) { 32 | if (res) { 33 | return patchNode(res, updates); 34 | } 35 | 36 | if (root) { 37 | res = root.querySelector(query); 38 | } else if (this.shadowRoot) { 39 | res = this.shadowRoot.querySelector(query); 40 | } else { 41 | res = this.querySelector(query); 42 | } 43 | 44 | if (!res) { 45 | throw new Error(`could not find ${query}`); 46 | } 47 | 48 | return patchNode(res, updates); 49 | }; 50 | } 51 | 52 | function patchNode( 53 | target: T, 54 | update?: Partial | ((node: T) => Partial), 55 | ): T { 56 | if (!update) { 57 | return target; 58 | } 59 | 60 | const patch = typeof update === "function" ? update(target) : update; 61 | 62 | for (const key in patch) { 63 | const newValue = patch[key]; 64 | const oldValue = target[key]; 65 | 66 | if (newValue !== oldValue) { 67 | Reflect.set(target, key, newValue); 68 | } 69 | } 70 | 71 | return target; 72 | } 73 | -------------------------------------------------------------------------------- /packages/element/src/lib/result.ts: -------------------------------------------------------------------------------- 1 | export interface ShadowResult { 2 | apply(el: Element): void; 3 | } 4 | -------------------------------------------------------------------------------- /packages/element/src/lib/tags.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { CSSResult, HTMLResult, css, html } from "./tags.js"; 3 | 4 | describe("tags", () => { 5 | describe("html", () => { 6 | it("should create an HTMLResult instance", () => { 7 | const result = html`
Hello
`; 8 | assert.instanceOf(result, HTMLResult); 9 | }); 10 | 11 | it("should create a cloneable node", () => { 12 | const result = html`
Hello
`; 13 | const node = result.createNode(); 14 | assert.instanceOf(node, Node); 15 | assert.equal(node.textContent, "Hello"); 16 | }); 17 | 18 | it("should apply HTML to shadow root", () => { 19 | const div = document.createElement("div"); 20 | const shadow = div.attachShadow({ mode: "open" }); 21 | 22 | const result = html`
Hello
`; 23 | result.apply(div); 24 | 25 | assert.equal(shadow.innerHTML, "
Hello
"); 26 | }); 27 | 28 | it("should not apply HTML if no shadow root", () => { 29 | const element = document.createElement("div"); 30 | const result = html`
Hello
`; 31 | result.apply(element); 32 | 33 | assert.equal(element.innerHTML, ""); 34 | }); 35 | }); 36 | 37 | describe("css", () => { 38 | it("should create a CSSResult instance", () => { 39 | const result = css`div { color: red; }`; 40 | assert.instanceOf(result, CSSResult); 41 | }); 42 | 43 | it("should create a stylesheet with correct content", () => { 44 | const result = css`div { color: red; }`; 45 | 46 | const element = document.createElement("div"); 47 | const shadow = element.attachShadow({ mode: "open" }); 48 | result.apply(element); 49 | 50 | assert.equal(shadow.adoptedStyleSheets.length, 1); 51 | assert.equal( 52 | shadow.adoptedStyleSheets[0].cssRules[0].cssText, 53 | "div { color: red; }", 54 | ); 55 | }); 56 | 57 | it("should apply CSS to shadow root", () => { 58 | const element = document.createElement("div"); 59 | const shadow = element.attachShadow({ mode: "open" }); 60 | const result = css`div { color: red; }`; 61 | result.apply(element); 62 | 63 | assert.equal(shadow.adoptedStyleSheets.length, 1); 64 | assert.equal( 65 | shadow.adoptedStyleSheets[0].cssRules[0].cssText, 66 | "div { color: red; }", 67 | ); 68 | }); 69 | 70 | it("should not apply CSS if no shadow root", () => { 71 | const element = document.createElement("div"); 72 | const result = css`div { color: red; }`; 73 | result.apply(element); 74 | 75 | assert.equal(element.shadowRoot, null); 76 | }); 77 | 78 | it("should append to existing style sheets", () => { 79 | const element = document.createElement("div"); 80 | const shadow = element.attachShadow({ mode: "open" }); 81 | 82 | const sheet1 = css`div { color: red; }`; 83 | const sheet2 = css`span { color: blue; }`; 84 | 85 | sheet1.apply(element); 86 | sheet2.apply(element); 87 | 88 | assert.equal(shadow.adoptedStyleSheets.length, 2); 89 | assert.equal( 90 | shadow.adoptedStyleSheets[0].cssRules[0].cssText, 91 | "div { color: red; }", 92 | ); 93 | assert.equal( 94 | shadow.adoptedStyleSheets[1].cssRules[0].cssText, 95 | "span { color: blue; }", 96 | ); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/element/src/lib/tags.ts: -------------------------------------------------------------------------------- 1 | import type { ShadowResult } from "./result.js"; 2 | 3 | export class HTMLResult implements ShadowResult { 4 | #template: HTMLTemplateElement; 5 | 6 | constructor(raw: TemplateStringsArray, ..._values: any[]) { 7 | this.#template = document.createElement("template"); 8 | this.#template.innerHTML = concat(raw); 9 | } 10 | 11 | createNode(): Node { 12 | return document.importNode(this.#template.content, true); 13 | } 14 | 15 | apply(el: T): void { 16 | if (el.shadowRoot) { 17 | el.shadowRoot.append(this.createNode()); 18 | } 19 | } 20 | } 21 | 22 | export function html( 23 | strings: TemplateStringsArray, 24 | ...values: any[] 25 | ): HTMLResult { 26 | return new HTMLResult(strings, ...values); 27 | } 28 | 29 | export class CSSResult implements ShadowResult { 30 | #sheet; 31 | 32 | constructor(raw: TemplateStringsArray, ..._values: any[]) { 33 | this.#sheet = new CSSStyleSheet(); 34 | this.#sheet.replaceSync(concat(raw)); 35 | } 36 | 37 | apply(el: HTMLElement): void { 38 | if (el.shadowRoot) { 39 | el.shadowRoot.adoptedStyleSheets = [ 40 | ...el.shadowRoot.adoptedStyleSheets, 41 | this.#sheet, 42 | ]; 43 | } 44 | } 45 | } 46 | 47 | export function css(strings: TemplateStringsArray): CSSResult { 48 | return new CSSResult(strings); 49 | } 50 | 51 | function concat(strings: TemplateStringsArray) { 52 | let res = ""; 53 | 54 | for (let i = 0; i < strings.length; i++) { 55 | res += strings[i]; 56 | } 57 | 58 | return res; 59 | } 60 | -------------------------------------------------------------------------------- /packages/element/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "target", 6 | "isolatedDeclarations": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/element/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare interface HTMLElement { 2 | connectedCallback?(): void; 3 | disconnectedCallback?(): void; 4 | attributeChangedCallback?( 5 | name: string, 6 | oldValue: string, 7 | newValue: string, 8 | ): void; 9 | } 10 | -------------------------------------------------------------------------------- /packages/element/wtr.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | rootDir: "../../", 3 | nodeResolve: { 4 | exportConditions: ["production"], 5 | }, 6 | files: "target/**/*.test.js", 7 | port: 9876, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/observable/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2020 Danny Blue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /packages/observable/README.md: -------------------------------------------------------------------------------- 1 | # Observable 2 | 3 | Adds the ability to monitor class properties (static and instance) for changes 4 | 5 | #### Installation: 6 | 7 | ```BASH 8 | npm i @joist/observable 9 | ``` 10 | 11 | ```TS 12 | import { observe, effect } from '@joist/observable'; 13 | 14 | class AppState { 15 | @observe() 16 | accessor todos: string[] = []; 17 | 18 | @observe() 19 | accessor userName?: string; 20 | 21 | @effect() 22 | onChange(changes: Changes) { 23 | console.log(changes); 24 | } 25 | } 26 | 27 | const state = new AppState(); 28 | 29 | state.todos = [...state.todos, 'Build Shit']; 30 | state.userName = 'Danny Blue' 31 | ``` 32 | 33 | ## Computed Properties 34 | 35 | The `@observe()` decorator can also be used to create computed properties that automatically update when their dependencies change. This is done by passing an options object with a `compute` function to the decorator: 36 | 37 | ```TS 38 | import { observe } from '@joist/observable'; 39 | 40 | class UserProfile { 41 | @observe() 42 | accessor firstName = "John"; 43 | 44 | @observe() 45 | accessor lastName = "Doe"; 46 | 47 | @observe({ 48 | compute: (i) => `${i.firstName} ${i.lastName}` 49 | }) 50 | accessor fullName = ""; 51 | } 52 | 53 | const profile = new UserProfile(); 54 | console.log(profile.fullName); // "John Doe" 55 | 56 | // When dependencies change, computed properties update automatically 57 | profile.firstName = "Jane"; 58 | console.log(profile.fullName); // "Jane Doe" 59 | ``` 60 | 61 | The compute function receives the instance as its parameter and should return the computed value. The computed property will automatically update whenever any of its dependencies (properties accessed within the compute function) change. 62 | -------------------------------------------------------------------------------- /packages/observable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joist/observable", 3 | "version": "4.2.4-next.7", 4 | "type": "module", 5 | "main": "./target/lib.js", 6 | "module": "./target/lib.js", 7 | "exports": { 8 | ".": "./target/lib.js", 9 | "./*": "./target/lib/*", 10 | "./package.json": "./package.json" 11 | }, 12 | "files": [ 13 | "src", 14 | "target" 15 | ], 16 | "sideEffects": false, 17 | "description": "Monitor and respond to object changes", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/deebloo/joist.git" 21 | }, 22 | "keywords": [ 23 | "TypeScript", 24 | "Observable", 25 | "WebComponents", 26 | "Reactive" 27 | ], 28 | "author": "deebloo", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/deebloo/joist/issues" 32 | }, 33 | "publishConfig": { 34 | "access": "public" 35 | }, 36 | "scripts": { 37 | "test": "wireit", 38 | "build": "wireit" 39 | }, 40 | "wireit": { 41 | "build": { 42 | "command": "tsc --build --pretty", 43 | "clean": "if-file-deleted", 44 | "files": [ 45 | "src/**", 46 | "tsconfig.json", 47 | "../../tsconfig.json" 48 | ], 49 | "output": [ 50 | "target/**", 51 | "tsconfig.tsbuildinfo" 52 | ] 53 | }, 54 | "test": { 55 | "command": "wtr --config wtr.config.mjs", 56 | "files": [ 57 | "wtr.config.mjs", 58 | "target/**" 59 | ], 60 | "output": [], 61 | "dependencies": [ 62 | "build" 63 | ] 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/observable/src/lib.ts: -------------------------------------------------------------------------------- 1 | export { observe, effect, ObserveOpts } from "./lib/observe.js"; 2 | export { Changes, Change } from "./lib/metadata.js"; 3 | export { instanceMetadataStore, observableMetadataStore } from "./lib/metadata.js"; 4 | -------------------------------------------------------------------------------- /packages/observable/src/lib/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { Changes, ObservableInstanceMetaDataStore } from "./metadata.js"; 4 | 5 | it("should return default metadata", () => { 6 | const key = {}; 7 | const data = new ObservableInstanceMetaDataStore().read(key); 8 | 9 | assert.deepEqual(data, { 10 | changes: new Changes(), 11 | scheduler: null, 12 | bindings: new Set<() => void>(), 13 | initialized: new Set(), 14 | }); 15 | }); 16 | 17 | it("should return the same metadata object after init", () => { 18 | const key = {}; 19 | const data = new ObservableInstanceMetaDataStore(); 20 | 21 | assert.equal(data.read(key), data.read(key)); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/observable/src/lib/metadata.ts: -------------------------------------------------------------------------------- 1 | (Symbol as any).metadata ??= Symbol("Symbol.metadata"); 2 | 3 | export type EffectFn = (changes: Changes) => void; 4 | 5 | export interface Change { 6 | oldValue: T; 7 | newValue: T; 8 | } 9 | 10 | export class Changes extends Map> {} 11 | 12 | export class ObservableInstanceMetadata { 13 | scheduler: Promise | null = null; 14 | changes: Changes = new Changes(); 15 | bindings: Set<(changes: Changes) => void> = new Set(); 16 | initialized: Set = new Set(); 17 | } 18 | 19 | export class ObservableInstanceMetaDataStore extends WeakMap< 20 | object, 21 | ObservableInstanceMetadata 22 | > { 23 | read(key: T): ObservableInstanceMetadata { 24 | let data = this.get(key); 25 | 26 | if (!data) { 27 | data = new ObservableInstanceMetadata(); 28 | 29 | this.set(key, data); 30 | } 31 | 32 | return data as ObservableInstanceMetadata; 33 | } 34 | } 35 | 36 | export class ObservableMetadata { 37 | effects: Set> = new Set(); 38 | } 39 | 40 | export class ObservableMetadataStore extends WeakMap> { 41 | read(key: object): ObservableMetadata { 42 | let data = this.get(key); 43 | 44 | if (!data) { 45 | data = new ObservableMetadata(); 46 | 47 | this.set(key, data); 48 | } 49 | 50 | return data as ObservableMetadata; 51 | } 52 | } 53 | 54 | export const instanceMetadataStore: ObservableInstanceMetaDataStore = 55 | new ObservableInstanceMetaDataStore(); 56 | 57 | export const observableMetadataStore: ObservableMetadataStore = new ObservableMetadataStore(); 58 | -------------------------------------------------------------------------------- /packages/observable/src/lib/observe.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import type { Changes } from "./metadata.js"; 4 | import { effect, observe } from "./observe.js"; 5 | 6 | it("should work with static accessors", () => { 7 | return new Promise((resolve) => { 8 | // biome-ignore lint/complexity/noStaticOnlyClass: 9 | class Counter { 10 | @observe() 11 | static accessor value = 0; 12 | 13 | @effect() static onPropChanged() { 14 | assert.equal(Counter.value, 1); 15 | 16 | resolve(); 17 | } 18 | } 19 | 20 | assert.equal(Counter.value, 0); 21 | 22 | Counter.value++; 23 | 24 | assert.equal(Counter.value, 1); 25 | }); 26 | }); 27 | 28 | it("should work with instance accessors", () => { 29 | return new Promise((resolve) => { 30 | class Counter { 31 | @observe() 32 | accessor value = 0; 33 | 34 | // confirm it works with private methods 35 | // @ts-ignore 36 | @effect() #onChange() { 37 | assert.equal(this.value, 1); 38 | 39 | resolve(); 40 | } 41 | } 42 | 43 | const counter = new Counter(); 44 | 45 | assert.equal(counter.value, 0); 46 | 47 | counter.value++; 48 | 49 | assert.equal(counter.value, 1); 50 | }); 51 | }); 52 | 53 | it("should return a set of changed props", () => { 54 | return new Promise((resolve) => { 55 | class Counter { 56 | @observe() accessor value = 0; 57 | 58 | @effect() onChange(changes: Changes) { 59 | assert.deepEqual(changes.get("value"), { 60 | oldValue: 0, 61 | newValue: 1, 62 | }); 63 | 64 | resolve(); 65 | } 66 | } 67 | 68 | const counter = new Counter(); 69 | counter.value++; 70 | }); 71 | }); 72 | 73 | it("should upgrade custom elements", () => { 74 | return new Promise((resolve) => { 75 | class Counter extends HTMLElement { 76 | @observe() 77 | accessor value = 0; 78 | 79 | constructor() { 80 | super(); 81 | 82 | assert.equal(this.value, 100); 83 | } 84 | 85 | @effect() onChange() { 86 | assert.equal(this.value, 101); 87 | 88 | resolve(); 89 | } 90 | } 91 | 92 | const el = document.createElement("observable-1") as Counter; 93 | el.value = 100; 94 | 95 | document.body.append(el); 96 | 97 | customElements.whenDefined("observable-1").then(() => { 98 | el.value++; 99 | }); 100 | 101 | customElements.define("observable-1", Counter); 102 | }); 103 | }); 104 | 105 | describe("computed decorator", () => { 106 | it("should compute values based on other properties", async () => { 107 | class TestClass { 108 | @observe() 109 | accessor firstName = "John"; 110 | 111 | @observe() 112 | accessor lastName = "Doe"; 113 | 114 | @observe({ 115 | compute: (i) => `${i.firstName} ${i.lastName}`, 116 | }) 117 | accessor fullName = ""; 118 | } 119 | 120 | const instance = new TestClass(); 121 | assert.equal(instance.fullName, "John Doe"); 122 | 123 | // Update dependencies 124 | instance.firstName = "Jane"; 125 | 126 | await Promise.resolve(); 127 | 128 | assert.equal(instance.fullName, "Jane Doe"); 129 | }); 130 | 131 | it("should handle multiple computed properties", async () => { 132 | class TestClass { 133 | @observe() 134 | accessor x = 2; 135 | 136 | @observe() 137 | accessor y = 3; 138 | 139 | @observe({ 140 | compute: (i) => i.x + i.y, 141 | }) 142 | accessor sum = 0; 143 | 144 | @observe({ 145 | compute: (i) => i.x * i.y, 146 | }) 147 | accessor product = 0; 148 | } 149 | 150 | const instance = new TestClass(); 151 | assert.equal(instance.sum, 5); 152 | assert.equal(instance.product, 6); 153 | 154 | // Update dependencies 155 | instance.x = 4; 156 | 157 | await Promise.resolve(); 158 | 159 | assert.equal(instance.sum, 7); 160 | assert.equal(instance.product, 12); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /packages/observable/src/lib/observe.ts: -------------------------------------------------------------------------------- 1 | import { type EffectFn, instanceMetadataStore, observableMetadataStore } from "./metadata.js"; 2 | 3 | const INIT_VALUE = Symbol("init"); 4 | 5 | export interface ObserveOpts { 6 | compute?: (instance: This) => Value; 7 | } 8 | 9 | export function observe(opts: ObserveOpts = {}) { 10 | return function observeDecorato( 11 | base: ClassAccessorDecoratorTarget, 12 | ctx: ClassAccessorDecoratorContext, 13 | ): ClassAccessorDecoratorResult { 14 | const observableMeta = observableMetadataStore.read(ctx.metadata); 15 | 16 | const compute = opts.compute; 17 | 18 | if (compute) { 19 | observableMeta.effects.add(function mapperFn(this: This) { 20 | ctx.access.set(this, compute(this)); 21 | }); 22 | } 23 | 24 | return { 25 | init(value) { 26 | let val: Value | typeof INIT_VALUE = INIT_VALUE; 27 | 28 | // START: Make upgradable custom elements work 29 | try { 30 | val = ctx.access.get(this); 31 | } catch {} 32 | 33 | if (val !== INIT_VALUE) { 34 | Reflect.deleteProperty(this, ctx.name); 35 | 36 | return val; 37 | } 38 | // END 39 | 40 | return value; 41 | }, 42 | get() { 43 | if (compute) { 44 | const instanceMeta = instanceMetadataStore.read(this); 45 | 46 | if (!instanceMeta.initialized.has(ctx.name)) { 47 | instanceMeta.initialized.add(ctx.name); 48 | 49 | return compute(this); 50 | } 51 | } 52 | 53 | return base.get.call(this); 54 | }, 55 | set(newValue: Value) { 56 | const oldValue = base.get.call(this); 57 | const instanceMeta = instanceMetadataStore.read(this); 58 | 59 | if (newValue !== oldValue) { 60 | if (instanceMeta.scheduler === null) { 61 | instanceMeta.scheduler = Promise.resolve().then(() => { 62 | for (const effect of observableMeta.effects) { 63 | effect.call(this, instanceMeta.changes); 64 | } 65 | 66 | for (const binding of instanceMeta.bindings) { 67 | binding.call(this, instanceMeta.changes); 68 | } 69 | 70 | instanceMeta.scheduler = null; 71 | instanceMeta.changes.clear(); 72 | }); 73 | } 74 | 75 | instanceMeta.changes.set(ctx.name as keyof This, { 76 | oldValue, 77 | newValue, 78 | }); 79 | 80 | base.set.call(this, newValue); 81 | } 82 | }, 83 | }; 84 | }; 85 | } 86 | 87 | export function effect() { 88 | return function effectDecorator( 89 | value: EffectFn, 90 | ctx: ClassMethodDecoratorContext, 91 | ): void { 92 | const data = observableMetadataStore.read(ctx.metadata); 93 | 94 | data.effects.add(value); 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /packages/observable/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "target", 6 | "isolatedDeclarations": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/observable/wtr.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | rootDir: "../../", 3 | nodeResolve: { 4 | exportConditions: ["production"], 5 | }, 6 | files: "target/**/*.test.js", 7 | port: 9877, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/plugin-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joist/plugin-vite", 3 | "version": "4.2.4-next.7", 4 | "type": "module", 5 | "main": "./target/lib.js", 6 | "module": "./target/lib.js", 7 | "exports": { 8 | ".": "./target/lib.js", 9 | "./package.json": "./package.json" 10 | }, 11 | "files": [ 12 | "src", 13 | "target" 14 | ], 15 | "sideEffects": false, 16 | "description": "server side render shadow dom (Declarative Shadow DOM)", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/deebloo/joist.git" 20 | }, 21 | "keywords": [], 22 | "author": "deebloo", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/deebloo/joist/issues" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "scripts": { 31 | "build": "wireit" 32 | }, 33 | "wireit": { 34 | "build": { 35 | "command": "tsc --build --pretty", 36 | "clean": "if-file-deleted", 37 | "files": [ 38 | "src/**", 39 | "tsconfig.json" 40 | ], 41 | "output": [ 42 | "target/**", 43 | "tsconfig.tsbuildinfo" 44 | ], 45 | "dependencies": [ 46 | "../ssr:build" 47 | ] 48 | }, 49 | "test": { 50 | "command": "mocha target/**/*.test.js", 51 | "files": [ 52 | "target/**" 53 | ], 54 | "output": [], 55 | "dependencies": [ 56 | "build" 57 | ] 58 | } 59 | }, 60 | "devDependencies": { 61 | "vite": "^6.0.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/plugin-vite/src/lib.ts: -------------------------------------------------------------------------------- 1 | import type { Applicator } from "@joist/ssr"; 2 | import type { PluginOption } from "vite"; 3 | 4 | function plugin(applicator: Applicator): PluginOption { 5 | return { 6 | name: "Joist", 7 | transformIndexHtml: { 8 | order: "pre", 9 | handler(html) { 10 | return applicator.apply(html, [ 11 | "joist-header", 12 | "joist-nav", 13 | "joist-main", 14 | ]); 15 | }, 16 | }, 17 | handleHotUpdate({ file, server }) { 18 | if ( 19 | file.includes("elements") && 20 | (file.endsWith(".html") || file.endsWith(".css")) 21 | ) { 22 | console.log(`${file} updated...`); 23 | 24 | server.ws.send({ 25 | type: "full-reload", 26 | path: "*", 27 | }); 28 | } 29 | }, 30 | }; 31 | } 32 | 33 | export default plugin; 34 | -------------------------------------------------------------------------------- /packages/plugin-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "target", 6 | "isolatedDeclarations": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/ssr/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2020 Danny Blue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /packages/ssr/README.md: -------------------------------------------------------------------------------- 1 | # SSR (Experimental) 2 | 3 | Render ShadowDOM on thee server with Declarative Shadow DOM. Parses HTML and recursively inserts user defined templates. The most important part of this would be the template loader. A template loader is an object that defines how the applicator will get the string values for both the html and the css. (css is optional). 4 | 5 | ```TS 6 | import { Applicator. NoopTemplateCache, FileSysTemplateLoader } from '@joist/ssr'; 7 | 8 | // Define a template caching strategy and a template loader 9 | const applicator = new Applicator( 10 | new NoopTemplateCache(), 11 | new FileSysTemplateLoader( 12 | (tag) => `elements/${tag}/${tag}.html`, 13 | (tag) => `elements/${tag}/${tag}.css` 14 | ) 15 | ); 16 | 17 | // Apply to a document and provide a list of elements to search for 18 | applicator.apply(document, []) 19 | ``` 20 | 21 | ## Vite 22 | 23 | ```TS 24 | import { Applicator. NoopTemplateCache, FileSysTemplateLoader } from '@joist/ssr'; 25 | import { defineConfig } from 'vite'; 26 | 27 | const applicator = new Applicator( 28 | new NoopTemplateCache(), 29 | new FileSysTemplateLoader( 30 | (tag) => `elements/${tag}/${tag}.html`, 31 | (tag) => `elements/${tag}/${tag}.css` 32 | ) 33 | ); 34 | 35 | export default defineConfig({ 36 | plugins: [ 37 | { 38 | transformIndexHtml: { 39 | enforce: "pre", 40 | transform(html) { 41 | return applicator.apply(html, ['my-element', 'my-dropdown']); 42 | } 43 | } 44 | } 45 | ], 46 | }); 47 | ``` 48 | -------------------------------------------------------------------------------- /packages/ssr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joist/ssr", 3 | "version": "4.2.4-next.7", 4 | "type": "module", 5 | "main": "./target/lib.js", 6 | "module": "./target/lib.js", 7 | "exports": { 8 | ".": "./target/lib.js", 9 | "./*": "./target/lib/*", 10 | "./package.json": "./package.json" 11 | }, 12 | "files": [ 13 | "src", 14 | "target" 15 | ], 16 | "sideEffects": false, 17 | "description": "server side render shadow dom (Declarative Shadow DOM)", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/deebloo/joist.git" 21 | }, 22 | "keywords": [], 23 | "author": "deebloo", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/deebloo/joist/issues" 27 | }, 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "scripts": { 32 | "test": "wireit", 33 | "build": "wireit" 34 | }, 35 | "wireit": { 36 | "build": { 37 | "command": "tsc --build --pretty", 38 | "clean": "if-file-deleted", 39 | "files": [ 40 | "src/**", 41 | "tsconfig.json" 42 | ], 43 | "output": [ 44 | "target/**", 45 | "tsconfig.tsbuildinfo" 46 | ] 47 | }, 48 | "test": { 49 | "command": "mocha target/**/*.test.js", 50 | "files": [ 51 | "target/**" 52 | ], 53 | "output": [], 54 | "dependencies": [ 55 | "build" 56 | ] 57 | } 58 | }, 59 | "dependencies": { 60 | "cheerio": "^1.0.0-rc.12" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/ssr/src/lib.ts: -------------------------------------------------------------------------------- 1 | export { Applicator } from "./lib/applicator.js"; 2 | export { TemplateCache, NoopTemplateCache } from "./lib/template-cache.js"; 3 | export { 4 | TemplateLoader, 5 | FileSysTemplateLoader, 6 | } from "./lib/template-loader.js"; 7 | -------------------------------------------------------------------------------- /packages/ssr/src/lib/applicator.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { Applicator } from "./applicator.js"; 4 | import { NoopTemplateCache } from "./template-cache.js"; 5 | import type { TemplateLoader } from "./template-loader.js"; 6 | 7 | it("should apply declarative shadow dom to specified elements", async () => { 8 | class MockTemplateLoader implements TemplateLoader { 9 | loadCSS(tag: string): Promise { 10 | return Promise.resolve(`:host { content: 'css for ${tag}' }`); 11 | } 12 | loadHTML(tag: string): Promise { 13 | return Promise.resolve(`
html for ${tag}
`); 14 | } 15 | } 16 | 17 | const applicator = new Applicator( 18 | new NoopTemplateCache(), 19 | new MockTemplateLoader(), 20 | ); 21 | 22 | const document = /*html*/ ` 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | `; 33 | 34 | const res = await applicator.apply(document, [ 35 | "mock-header", 36 | "mock-content", 37 | "mock-footer", 38 | ]); 39 | 40 | assert.equal( 41 | trim(res), 42 | trim(` 43 | 44 | 45 | 46 | 47 | 48 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 66 | 67 | 68 | 69 | `), 70 | ); 71 | }); 72 | 73 | it("should apply declarative shadow dom recursively", async () => { 74 | class MockTemplateLoader implements TemplateLoader { 75 | async loadCSS(tag: string): Promise { 76 | return `:host { content: 'css for ${tag}' }`; 77 | } 78 | 79 | async loadHTML(tag: string): Promise { 80 | switch (tag) { 81 | case "mock-foo": 82 | return ""; 83 | 84 | case "mock-bar": 85 | return ""; 86 | } 87 | 88 | return `
html for ${tag}
`; 89 | } 90 | } 91 | 92 | const applicator = new Applicator( 93 | new NoopTemplateCache(), 94 | new MockTemplateLoader(), 95 | ); 96 | 97 | const document = ""; 98 | 99 | const res = await applicator.apply(document, [ 100 | "mock-foo", 101 | "mock-bar", 102 | "mock-baz", 103 | ]); 104 | 105 | assert.equal( 106 | trim(res), 107 | trim(` 108 | 109 | 110 | 111 | 112 | 113 | 127 | 128 | 129 | 130 | `), 131 | ); 132 | }); 133 | 134 | function trim(value: string) { 135 | return value.replace(/\s+/g, "").replace(/(\r\n|\n|\r)/gm, ""); 136 | } 137 | -------------------------------------------------------------------------------- /packages/ssr/src/lib/applicator.ts: -------------------------------------------------------------------------------- 1 | import { type CheerioAPI, load } from "cheerio"; 2 | 3 | import type { TemplateCache } from "./template-cache.js"; 4 | import type { TemplateLoader } from "./template-loader.js"; 5 | 6 | export interface ApplicatorOpts { 7 | templateCache: TemplateCache; 8 | templateLoader: TemplateLoader; 9 | } 10 | 11 | export class Applicator { 12 | #templateCache: TemplateCache; 13 | #templateLoader: TemplateLoader; 14 | 15 | constructor(templateCache: TemplateCache, templateLoader: TemplateLoader) { 16 | this.#templateCache = templateCache; 17 | this.#templateLoader = templateLoader; 18 | } 19 | 20 | async apply(document: string, elements: string[]): Promise { 21 | const $ = load(document); 22 | 23 | return this.build($, elements); 24 | } 25 | 26 | async build($: CheerioAPI, elements: string[]): Promise { 27 | for (let i = 0; i < elements.length; i++) { 28 | const element = elements[i]; 29 | const node = $(element); 30 | 31 | if (node.length) { 32 | let elementTemplate = await this.#templateCache.get(element); 33 | 34 | if (!elementTemplate) { 35 | const template = await this.#buildTemplate(element); 36 | elementTemplate = await this.build( 37 | load(template, null, false), 38 | elements, 39 | ); 40 | 41 | await this.#templateCache.set(element, elementTemplate); 42 | } 43 | 44 | if (node.find("> template[shadowrootmode]").length === 0) { 45 | node.prepend(elementTemplate); 46 | } 47 | } 48 | } 49 | 50 | return $.html(); 51 | } 52 | 53 | async #buildTemplate(tag: string) { 54 | const [html, styles] = await Promise.all([ 55 | this.#templateLoader.loadHTML(tag), 56 | this.#templateLoader.loadCSS(tag), 57 | ]); 58 | 59 | return ` 63 | `; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/ssr/src/lib/template-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | 3 | import { NoopTemplateCache, TemplateCache } from "./template-cache.js"; 4 | 5 | it("should cache (in memory)", async () => { 6 | const cache = new TemplateCache(); 7 | 8 | await cache.set("foo-bar", "

Hello World

"); 9 | 10 | assert.equal(await cache.get("foo-bar"), "

Hello World

"); 11 | }); 12 | 13 | it("should never cache (noop)", async () => { 14 | const cache = new NoopTemplateCache(); 15 | 16 | await cache.set("foo-bar", "

Hello World

"); 17 | 18 | assert.equal(await cache.get("foo-bar"), undefined); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/ssr/src/lib/template-cache.ts: -------------------------------------------------------------------------------- 1 | export class TemplateCache { 2 | #cache = new Map(); 3 | 4 | async get(key: string): Promise { 5 | return this.#cache.get(key); 6 | } 7 | 8 | async set(key: string, val: string): Promise { 9 | this.#cache.set(key, val); 10 | 11 | return this; 12 | } 13 | } 14 | 15 | export class NoopTemplateCache extends TemplateCache { 16 | async get(_: string): Promise { 17 | return undefined; 18 | } 19 | 20 | async set(_key: string, _val: string): Promise { 21 | return this; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/ssr/src/lib/template-loader.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import { assert } from "chai"; 4 | 5 | import { FileSysTemplateLoader } from "./template-loader.js"; 6 | 7 | const dirname = join( 8 | fileURLToPath(new URL(".", import.meta.url)), 9 | "../../src/testing", 10 | ); 11 | 12 | it("FileSysTemplateLoader: should read from defined paths", async () => { 13 | const loader = new FileSysTemplateLoader( 14 | (tag) => join(dirname, "elements", tag, `${tag}.html`), 15 | (tag) => join(dirname, "elements", tag, `${tag}.css`), 16 | ); 17 | 18 | const html = await loader.loadHTML("my-element"); 19 | const css = await loader.loadCSS("my-element"); 20 | 21 | assert.equal(html?.trim(), "

Hello World

\n\n"); 22 | assert.equal(css?.trim(), ":host {\n display: flex;\n}"); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/ssr/src/lib/template-loader.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | 3 | /** 4 | * A template loader defines how css and html are generated for a given component template. 5 | */ 6 | export interface TemplateLoader { 7 | loadHTML(tag: string): Promise; 8 | loadCSS(tag: string): Promise; 9 | } 10 | 11 | export type PathFn = (tag: string) => string; 12 | 13 | export class FileSysTemplateLoader implements TemplateLoader { 14 | #html: PathFn; 15 | #css: PathFn; 16 | 17 | constructor(html: PathFn, css: PathFn) { 18 | this.#html = html; 19 | this.#css = css; 20 | } 21 | 22 | async loadHTML(tag: string): Promise { 23 | try { 24 | return await readFile(this.#html(tag)).then((res) => res.toString()); 25 | } catch { 26 | return null; 27 | } 28 | } 29 | 30 | async loadCSS(tag: string): Promise { 31 | try { 32 | return await readFile(this.#css(tag)).then((res) => res.toString()); 33 | } catch { 34 | return null; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/ssr/src/testing/elements/my-element/my-element.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | } 4 | -------------------------------------------------------------------------------- /packages/ssr/src/testing/elements/my-element/my-element.html: -------------------------------------------------------------------------------- 1 |

Hello World

2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/ssr/src/testing/elements/my-element/my-element.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joist-framework/joist/5ae51caa97a1278ad2498c188c46755af6a11eaf/packages/ssr/src/testing/elements/my-element/my-element.ts -------------------------------------------------------------------------------- /packages/ssr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "target", 6 | "isolatedDeclarations": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/templating/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2020 Danny Blue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /packages/templating/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joist/templating", 3 | "version": "4.2.4-next.7", 4 | "type": "module", 5 | "main": "./target/lib.js", 6 | "module": "./target/lib.js", 7 | "exports": { 8 | ".": "./target/lib.js", 9 | "./*": "./target/lib/*", 10 | "./package.json": "./package.json" 11 | }, 12 | "files": [ 13 | "src", 14 | "target" 15 | ], 16 | "description": "Intelligently apply styles to WebComponents", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/joist-framework/joist.git" 20 | }, 21 | "keywords": [ 22 | "TypeScript", 23 | "WebComponents", 24 | "CSS", 25 | "ShadowDOM" 26 | ], 27 | "author": "deebloo", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/joist-framework/joist/issues" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "scripts": { 36 | "test": "wireit", 37 | "build": "wireit" 38 | }, 39 | "wireit": { 40 | "build": { 41 | "command": "tsc --build --pretty", 42 | "clean": "if-file-deleted", 43 | "files": [ 44 | "src/**", 45 | "tsconfig.json", 46 | "../../tsconfig.json" 47 | ], 48 | "output": [ 49 | "target/**", 50 | "tsconfig.tsbuildinfo" 51 | ], 52 | "dependencies": [ 53 | "../observable:build", 54 | "../element:build" 55 | ] 56 | }, 57 | "test": { 58 | "command": "wtr --config wtr.config.mjs", 59 | "files": [ 60 | "vitest.config.js", 61 | "target/**" 62 | ], 63 | "dependencies": [ 64 | "build" 65 | ] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/templating/src/lib.ts: -------------------------------------------------------------------------------- 1 | export { bind } from "./lib/bind.js"; 2 | export { JoistValueEvent } from "./lib/events.js"; 3 | -------------------------------------------------------------------------------- /packages/templating/src/lib/bind.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { bind } from "./bind.js"; 3 | import { JoistValueEvent } from "./events.js"; 4 | import { JExpression } from "./expression.js"; 5 | 6 | describe("bind decorator", () => { 7 | class TestElement extends HTMLElement { 8 | @bind() 9 | accessor value = "initial"; 10 | 11 | @bind({ alwaysUpdate: true }) 12 | accessor alwaysUpdateValue = "initial"; 13 | } 14 | 15 | customElements.define("test-element", TestElement); 16 | 17 | it("should initialize with default value", () => { 18 | const element = new TestElement(); 19 | assert.equal(element.value, "initial"); 20 | }); 21 | 22 | it("should update value and trigger binding", async () => { 23 | const element = new TestElement(); 24 | let oldValue: unknown = null; 25 | let newValue: unknown = null; 26 | 27 | element.dispatchEvent( 28 | new JoistValueEvent(new JExpression("value"), (update) => { 29 | oldValue = update.oldValue; 30 | newValue = update.newValue; 31 | }), 32 | ); 33 | 34 | assert.equal(oldValue, null); 35 | assert.equal(newValue, "initial"); 36 | 37 | element.value = "updated"; 38 | 39 | await Promise.resolve(); 40 | 41 | assert.equal(oldValue, "initial"); 42 | assert.equal(newValue, "updated"); 43 | }); 44 | 45 | it("should trigger binding on every change with alwaysUpdate option", async () => { 46 | const element = new TestElement(); 47 | let bindingCount = 0; 48 | let oldValue: unknown; 49 | let newValue: unknown; 50 | 51 | element.dispatchEvent( 52 | new JoistValueEvent(new JExpression("alwaysUpdateValue"), (update) => { 53 | bindingCount++; 54 | oldValue = update.oldValue; 55 | newValue = update.newValue; 56 | }), 57 | ); 58 | 59 | assert.equal(bindingCount, 1); 60 | assert.equal(oldValue, null); 61 | assert.equal(newValue, "initial"); 62 | 63 | // Change some other value in the model 64 | element.value = "something else"; 65 | 66 | await Promise.resolve(); 67 | 68 | assert.equal(bindingCount, 2); 69 | assert.equal(oldValue, "initial"); 70 | assert.equal(newValue, "initial"); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/templating/src/lib/bind.ts: -------------------------------------------------------------------------------- 1 | import { instanceMetadataStore, observe, ObserveOpts } from "@joist/observable"; 2 | 3 | export interface BindOpts extends ObserveOpts { 4 | /** 5 | * Trigger bindings on every change cycle, regardless of value, 6 | * newValue and oldValue will be the same in that case 7 | **/ 8 | alwaysUpdate?: boolean; 9 | } 10 | 11 | export function bind(opts: BindOpts = {}) { 12 | return function bindDecorator( 13 | base: ClassAccessorDecoratorTarget, 14 | ctx: ClassAccessorDecoratorContext, 15 | ): ClassAccessorDecoratorResult { 16 | const internalObserve = observe(opts)(base, ctx); 17 | 18 | return { 19 | init(value) { 20 | this.addEventListener("joist::value", (e) => { 21 | if (e.expression.bindTo === ctx.name) { 22 | const instanceMeta = instanceMetadataStore.read(this); 23 | 24 | e.stopPropagation(); 25 | 26 | e.update({ 27 | oldValue: null, 28 | newValue: ctx.access.get(this), 29 | alwaysUpdate: opts.alwaysUpdate, 30 | firstChange: true, 31 | }); 32 | 33 | const name = ctx.name as keyof This; 34 | 35 | instanceMeta.bindings.add((changes) => { 36 | const change = changes.get(name); 37 | 38 | if (change) { 39 | e.update({ ...change, alwaysUpdate: opts.alwaysUpdate, firstChange: false }); 40 | } else if (opts.alwaysUpdate) { 41 | const value = ctx.access.get(this); 42 | 43 | e.update({ 44 | oldValue: value, 45 | newValue: value, 46 | alwaysUpdate: opts.alwaysUpdate, 47 | firstChange: false, 48 | }); 49 | } 50 | }); 51 | } 52 | }); 53 | 54 | if (internalObserve.init) { 55 | return internalObserve.init.call(this, value) as Value; 56 | } 57 | 58 | return value; 59 | }, 60 | get: internalObserve.get, 61 | set: internalObserve.set, 62 | }; 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /packages/templating/src/lib/define.ts: -------------------------------------------------------------------------------- 1 | import "./elements/async.element.js"; 2 | import "./elements/for.element.js"; 3 | import "./elements/if.element.js"; 4 | import "./elements/bind.element.js"; 5 | import "./elements/value.element.js"; 6 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/async.element.ts: -------------------------------------------------------------------------------- 1 | import { attr, element, queryAll, css, html } from "@joist/element"; 2 | 3 | import { bind } from "../bind.js"; 4 | import { JoistValueEvent } from "../events.js"; 5 | import { JExpression } from "../expression.js"; 6 | 7 | declare global { 8 | interface HTMLElementTagNameMap { 9 | "j-async": JoistAsyncElement; 10 | } 11 | } 12 | 13 | export type AsyncState = { 14 | status: "loading" | "error" | "success"; 15 | data?: T; 16 | error?: E; 17 | }; 18 | 19 | @element({ 20 | tagName: "j-async", 21 | // prettier-ignore 22 | shadowDom: [css`:host{display: contents;}`, html``], 23 | }) 24 | export class JoistAsyncElement extends HTMLElement { 25 | @attr() 26 | accessor bind = ""; 27 | 28 | @bind() 29 | accessor state: AsyncState | null = null; 30 | 31 | #templates = queryAll("template", this); 32 | #currentNodes: Node[] = []; 33 | #cachedTemplates: { 34 | loading?: HTMLTemplateElement; 35 | error?: HTMLTemplateElement; 36 | success?: HTMLTemplateElement; 37 | } = { 38 | loading: undefined, 39 | error: undefined, 40 | success: undefined, 41 | }; 42 | 43 | connectedCallback(): void { 44 | this.#clean(); 45 | 46 | // Cache all templates 47 | const templates = Array.from(this.#templates()); 48 | 49 | this.#cachedTemplates = { 50 | loading: templates.find((t) => t.hasAttribute("loading")), 51 | error: templates.find((t) => t.hasAttribute("error")), 52 | success: templates.find((t) => t.hasAttribute("success")), 53 | }; 54 | 55 | const token = new JExpression(this.bind); 56 | 57 | this.dispatchEvent( 58 | new JoistValueEvent(token, ({ newValue, oldValue }) => { 59 | if (newValue !== oldValue) { 60 | if (newValue instanceof Promise) { 61 | this.#handlePromise(newValue); 62 | } else if (this.#isAsyncState(newValue)) { 63 | this.#handleState(newValue); 64 | } else { 65 | console.warn("j-async bind value must be a Promise or AsyncState"); 66 | } 67 | } 68 | }), 69 | ); 70 | } 71 | 72 | #isAsyncState(value: unknown): value is AsyncState { 73 | return ( 74 | typeof value === "object" && 75 | value !== null && 76 | "status" in value && 77 | (value.status === "loading" || value.status === "error" || value.status === "success") 78 | ); 79 | } 80 | 81 | async #handlePromise(promise: Promise): Promise { 82 | try { 83 | this.#handleState({ status: "loading" }); 84 | const data = await promise; 85 | this.#handleState({ status: "success", data }); 86 | } catch (error) { 87 | this.#handleState({ status: "error", error }); 88 | } 89 | } 90 | 91 | #handleState(state: AsyncState): void { 92 | this.#clean(); 93 | 94 | let template: HTMLTemplateElement | undefined = undefined; 95 | 96 | this.state = state; 97 | 98 | switch (state.status) { 99 | case "loading": 100 | template = this.#cachedTemplates.loading; 101 | break; 102 | 103 | case "error": 104 | template = this.#cachedTemplates.error; 105 | break; 106 | 107 | case "success": 108 | template = this.#cachedTemplates.success; 109 | break; 110 | } 111 | 112 | if (template) { 113 | const content = document.importNode(template.content, true); 114 | const nodes = Array.from(content.childNodes); 115 | this.appendChild(content); 116 | this.#currentNodes = nodes; 117 | } 118 | } 119 | 120 | #clean(): void { 121 | for (const node of this.#currentNodes) { 122 | node.parentNode?.removeChild(node); 123 | } 124 | this.#currentNodes = []; 125 | } 126 | 127 | disconnectedCallback(): void { 128 | this.#clean(); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/bind.element.test.ts: -------------------------------------------------------------------------------- 1 | import "./bind.element.js"; 2 | 3 | import { fixtureSync, html } from "@open-wc/testing"; 4 | import { assert } from "chai"; 5 | 6 | import type { JoistValueEvent } from "../events.js"; 7 | 8 | it("should pass props to child", () => { 9 | const element = fixtureSync(html` 10 |
{ 12 | if (e.expression.bindTo === "href") { 13 | e.update({ 14 | oldValue: null, 15 | newValue: "$foo", 16 | }); 17 | } 18 | 19 | if (e.expression.bindTo === "target") { 20 | e.update({ 21 | oldValue: null, 22 | newValue: { 23 | value: "_blank", 24 | }, 25 | }); 26 | } 27 | }} 28 | > 29 | 30 | Hello World 31 | 32 |
33 | `); 34 | 35 | const anchor = element.querySelector("a"); 36 | 37 | assert.equal(anchor?.getAttribute("href"), "$foo"); 38 | assert.equal(anchor?.getAttribute("target"), "_blank"); 39 | }); 40 | 41 | it("should pass props to specified child", () => { 42 | const element = fixtureSync(html` 43 |
{ 45 | e.update({ 46 | oldValue: null, 47 | newValue: "#foo", 48 | }); 49 | }} 50 | > 51 | 52 | Default 53 | Target 54 | 55 |
56 | `); 57 | 58 | const anchor = element.querySelectorAll("a"); 59 | 60 | assert.equal(anchor[0].getAttribute("href"), null); 61 | assert.equal(anchor[1].getAttribute("href"), "#foo"); 62 | }); 63 | 64 | it("should be case sensitive", () => { 65 | const element = fixtureSync(html` 66 |
{ 68 | e.update({ oldValue: null, newValue: 8 }); 69 | }} 70 | > 71 | 77 | 78 | 79 |
80 | `); 81 | 82 | const input = element.querySelector("input"); 83 | 84 | assert.equal(input?.selectionStart, 8); 85 | assert.equal(input?.selectionEnd, 8); 86 | }); 87 | 88 | it("should default to the mapTo value if bindTo is not provided", () => { 89 | const element = fixtureSync(html` 90 |
{ 92 | e.update({ oldValue: null, newValue: 8 }); 93 | }} 94 | > 95 | 96 | 97 | 98 |
99 | `); 100 | 101 | const input = element.querySelector("input"); 102 | 103 | assert.equal(input?.selectionStart, 8); 104 | assert.equal(input?.selectionEnd, 8); 105 | }); 106 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/bind.element.ts: -------------------------------------------------------------------------------- 1 | import { attr, element, css, html } from "@joist/element"; 2 | 3 | import { JExpression } from "../expression.js"; 4 | import { JoistValueEvent } from "../events.js"; 5 | 6 | declare global { 7 | interface HTMLElementTagNameMap { 8 | "j-bind": JoistBindElement; 9 | } 10 | } 11 | 12 | export class JAttrToken extends JExpression { 13 | mapTo: string; 14 | 15 | constructor(binding: string) { 16 | const [mapTo, bindTo] = binding.split(":"); 17 | 18 | super(bindTo ?? mapTo); 19 | 20 | this.mapTo = mapTo; 21 | } 22 | } 23 | 24 | @element({ 25 | tagName: "j-bind", 26 | // prettier-ignore 27 | shadowDom: [css`:host{display: contents;}`, html``], 28 | }) 29 | export class JoistBindElement extends HTMLElement { 30 | @attr() 31 | accessor props = ""; 32 | 33 | @attr() 34 | accessor attrs = ""; 35 | 36 | @attr() 37 | accessor target = ""; 38 | 39 | connectedCallback(): void { 40 | const attrBindings = this.#parseBinding(this.attrs); 41 | const propBindings = this.#parseBinding(this.props); 42 | 43 | let child = this.firstElementChild; 44 | 45 | if (this.target) { 46 | child = this.querySelector(this.target); 47 | } 48 | 49 | if (!child) { 50 | throw new Error("j-bind must have a child element or defined target"); 51 | } 52 | 53 | for (const attrValue of attrBindings) { 54 | const token = new JAttrToken(attrValue); 55 | 56 | this.#dispatch(token, (value) => { 57 | if (value === true) { 58 | child.setAttribute(token.mapTo, ""); 59 | } else if (value === false) { 60 | child.removeAttribute(token.mapTo); 61 | } else { 62 | child.setAttribute(token.mapTo, String(value)); 63 | } 64 | }); 65 | } 66 | 67 | for (const propValue of propBindings) { 68 | const token = new JAttrToken(propValue); 69 | 70 | this.#dispatch(token, (value) => { 71 | Reflect.set(child, token.mapTo, value); 72 | }); 73 | } 74 | } 75 | 76 | #parseBinding(binding: string) { 77 | return binding 78 | .split(",") 79 | .map((b) => b.trim()) 80 | .filter((b) => b); 81 | } 82 | 83 | #dispatch(token: JExpression, write: (value: unknown) => void) { 84 | this.dispatchEvent( 85 | new JoistValueEvent(token, ({ newValue, oldValue, alwaysUpdate }) => { 86 | if (newValue === oldValue && !alwaysUpdate) { 87 | return; 88 | } 89 | 90 | let valueToWrite = token.evaluate(newValue); 91 | 92 | if (token.isNegated) { 93 | valueToWrite = !valueToWrite; 94 | } 95 | 96 | write(valueToWrite); 97 | }), 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/for.element.ts: -------------------------------------------------------------------------------- 1 | import { attr, element, query, css, html } from "@joist/element"; 2 | 3 | import { bind } from "../bind.js"; 4 | import { JoistValueEvent } from "../events.js"; 5 | import { JExpression } from "../expression.js"; 6 | 7 | declare global { 8 | interface HTMLElementTagNameMap { 9 | "j-for": JositForElement; 10 | "j-for-scope": JForScope; 11 | } 12 | } 13 | 14 | export interface EachCtx { 15 | value: T | null; 16 | index: number | null; 17 | position: number | null; 18 | } 19 | 20 | @element({ 21 | tagName: "j-for-scope", 22 | // prettier-ignore 23 | shadowDom: [css`:host{display: contents;}`, html``], 24 | }) 25 | export class JForScope extends HTMLElement { 26 | @bind() 27 | accessor each: EachCtx = { 28 | value: null, 29 | index: null, 30 | position: null, 31 | }; 32 | 33 | @attr() 34 | accessor key: unknown; 35 | } 36 | 37 | @element({ 38 | tagName: "j-for", 39 | // prettier-ignore 40 | shadowDom: [css`:host{display:contents;}`, html``], 41 | }) 42 | export class JositForElement extends HTMLElement { 43 | @attr() 44 | accessor bind = ""; 45 | 46 | @attr() 47 | accessor key = ""; 48 | 49 | #template = query("template", this); 50 | #items: Iterable = []; 51 | #scopes = new Map(); 52 | 53 | connectedCallback(): void { 54 | const template = this.#template(); 55 | 56 | if (this.firstElementChild !== template) { 57 | throw new Error("The first Node in j-for needs to be a template"); 58 | } 59 | 60 | // collect all scopes from the template to be matched against later 61 | let currentScope = template.nextElementSibling; 62 | while (currentScope instanceof JForScope) { 63 | this.#scopes.set(currentScope.key, currentScope); 64 | currentScope = currentScope.nextElementSibling; 65 | } 66 | 67 | const token = new JExpression(this.bind); 68 | 69 | this.dispatchEvent( 70 | new JoistValueEvent(token, ({ newValue, oldValue }) => { 71 | if (newValue !== oldValue) { 72 | if (isIterable(newValue)) { 73 | this.#items = newValue; 74 | } else { 75 | this.#items = []; 76 | } 77 | 78 | // If there are no existing items in the DOM (template is the only child), 79 | // create all items from scratch 80 | if (template.nextSibling === null) { 81 | this.createFromEmpty(); 82 | } else { 83 | // Otherwise update existing items, reusing DOM nodes where possible 84 | this.updateItems(); 85 | } 86 | } 87 | }), 88 | ); 89 | } 90 | 91 | // Updates the DOM by either inserting new scopes or moving existing ones 92 | // to their correct positions based on the current iteration order 93 | createFromEmpty(): void { 94 | const template = this.#template(); 95 | const templateContent = template.content; 96 | const keyProperty = this.key; 97 | const fragment = document.createDocumentFragment(); 98 | 99 | let index = 0; 100 | for (const value of this.#items) { 101 | let key: unknown = index; 102 | 103 | if (keyProperty && hasProperty(value, keyProperty)) { 104 | key = value[keyProperty]; 105 | } 106 | 107 | const scope = new JForScope(); 108 | scope.append(document.importNode(templateContent, true)); 109 | scope.key = key; 110 | scope.each = { position: index + 1, index, value }; 111 | 112 | fragment.appendChild(scope); 113 | this.#scopes.set(key, scope); 114 | index++; 115 | } 116 | 117 | this.append(fragment); 118 | } 119 | 120 | // Updates the DOM by either inserting new scopes or moving existing ones 121 | // to their correct positions based on the current iteration order 122 | updateItems(): void { 123 | const template = this.#template(); 124 | const leftoverScopes = new Map(this.#scopes); 125 | const keyProperty = this.key; 126 | 127 | let index = 0; 128 | 129 | for (const value of this.#items) { 130 | let key: unknown = index; 131 | 132 | if (keyProperty && hasProperty(value, keyProperty)) { 133 | key = value[keyProperty]; 134 | } 135 | 136 | let scope = leftoverScopes.get(key); 137 | 138 | if (!scope) { 139 | scope = new JForScope(); 140 | scope.append(document.importNode(template.content, true)); 141 | this.#scopes.set(key, scope); 142 | } else { 143 | leftoverScopes.delete(key); // Remove from map to track unused scopes 144 | } 145 | 146 | // Only update if values have changed 147 | if (scope.key !== key || scope.each.value !== value) { 148 | scope.key = key; 149 | scope.each = { position: index + 1, index, value }; 150 | } 151 | 152 | const child = this.children[index + 1]; 153 | 154 | if (child !== scope) { 155 | this.insertBefore(scope, child); 156 | } 157 | 158 | index++; 159 | } 160 | 161 | // Remove unused scopes 162 | for (const scope of leftoverScopes.values()) { 163 | scope.remove(); 164 | } 165 | } 166 | 167 | disconnectedCallback(): void { 168 | for (const scope of this.#scopes.values()) { 169 | scope.remove(); 170 | } 171 | 172 | this.#scopes.clear(); 173 | this.#items = []; 174 | } 175 | } 176 | 177 | function isIterable(obj: any): obj is Iterable { 178 | return obj != null && typeof obj[Symbol.iterator] === "function"; 179 | } 180 | 181 | function hasProperty(item: unknown, key: string): item is Record { 182 | return Object.prototype.hasOwnProperty.call(item, key); 183 | } 184 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/if.element.ts: -------------------------------------------------------------------------------- 1 | import { attr, element, queryAll, css, html } from "@joist/element"; 2 | 3 | import { JoistValueEvent } from "../events.js"; 4 | import { JExpression } from "../expression.js"; 5 | 6 | declare global { 7 | interface HTMLElementTagNameMap { 8 | "j-if": JoistIfElement; 9 | } 10 | } 11 | 12 | @element({ 13 | tagName: "j-if", 14 | // prettier-ignore 15 | shadowDom: [css`:host{display: contents;}`, html``], 16 | }) 17 | export class JoistIfElement extends HTMLElement { 18 | @attr() 19 | accessor bind = ""; 20 | 21 | #templates = queryAll("template", this); 22 | 23 | connectedCallback(): void { 24 | const templates = Array.from(this.#templates()); 25 | 26 | if (templates.length === 0) { 27 | throw new Error("j-if requires at least one template element"); 28 | } 29 | 30 | if (templates.length > 2) { 31 | throw new Error("j-if can only have two template elements (if and else)"); 32 | } 33 | 34 | if (templates.length === 2 && !templates.some((t) => t.hasAttribute("else"))) { 35 | throw new Error("When using two templates, one must have the else attribute"); 36 | } 37 | 38 | if (templates.length === 2 && templates[0].hasAttribute("else")) { 39 | // Swap templates to ensure if template is first 40 | [templates[0], templates[1]] = [templates[1], templates[0]]; 41 | } 42 | 43 | // make sure there are no other nodes after the template 44 | this.#clean(); 45 | 46 | const token = new JExpression(this.bind); 47 | 48 | this.dispatchEvent( 49 | new JoistValueEvent(token, ({ newValue, oldValue, firstChange }) => { 50 | if (firstChange || newValue !== oldValue) { 51 | this.apply(token.evaluate(newValue), token.isNegated); 52 | } 53 | }), 54 | ); 55 | } 56 | 57 | apply(value: unknown, isNegative: boolean): void { 58 | this.#clean(); 59 | 60 | const templates = this.#templates(); 61 | 62 | const shouldShowIf = isNegative ? !value : value; 63 | const templateToUse = shouldShowIf ? templates[0] : templates[1]; 64 | 65 | if (templateToUse) { 66 | const content = document.importNode(templateToUse.content, true); 67 | 68 | this.appendChild(content); 69 | } 70 | } 71 | 72 | #clean(): void { 73 | while (!(this.lastChild instanceof HTMLTemplateElement)) { 74 | this.lastChild?.remove(); 75 | } 76 | } 77 | 78 | disconnectedCallback(): void { 79 | this.#clean(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/scope.ts: -------------------------------------------------------------------------------- 1 | import { attr, element, css, html, listen } from "@joist/element"; 2 | 3 | import type { JoistValueEvent } from "../events.js"; 4 | 5 | declare global { 6 | interface HTMLElementTagNameMap { 7 | "j-scope": JoistScopeElement; 8 | } 9 | } 10 | 11 | @element({ 12 | tagName: "j-val", 13 | // prettier-ignore 14 | shadowDom: [css`:host{display: contents;}`, html``], 15 | }) 16 | export class JoistScopeElement extends HTMLElement { 17 | @attr() 18 | accessor name = ""; 19 | 20 | @attr() 21 | accessor value = ""; 22 | 23 | #binding: JoistValueEvent | null = null; 24 | 25 | @listen("joist::value") 26 | onJoistValueFound(e: JoistValueEvent): void { 27 | if (e.expression.bindTo === this.name) { 28 | e.stopPropagation(); 29 | 30 | this.#binding = e; 31 | 32 | this.#binding.update({ oldValue: null, newValue: this.value }); 33 | } 34 | } 35 | 36 | attributeChangedCallback(_: string, oldValue: string, newValue: string): void { 37 | this.#binding?.update({ oldValue, newValue }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/value.element.test.ts: -------------------------------------------------------------------------------- 1 | import "./value.element.js"; 2 | 3 | import { fixtureSync, html } from "@open-wc/testing"; 4 | import { assert } from "chai"; 5 | 6 | import type { JoistValueEvent } from "../events.js"; 7 | 8 | it("should render content when the bind value is truthy", () => { 9 | const element = fixtureSync(html` 10 |
{ 12 | e.update({ oldValue: null, newValue: "Hello World" }); 13 | }} 14 | > 15 | 16 |
17 | `); 18 | 19 | assert.equal(element.textContent?.trim(), "Hello World"); 20 | }); 21 | 22 | it("should not write null values to textContent", () => { 23 | const element = fixtureSync(html` 24 |
{ 26 | e.update({ oldValue: "Hello", newValue: null }); 27 | }} 28 | > 29 | Hello World 30 |
31 | `); 32 | 33 | assert.equal(element.textContent?.trim(), "Hello World"); 34 | }); 35 | 36 | it("should not write undefined values to textContent", () => { 37 | const element = fixtureSync(html` 38 |
{ 40 | e.update({ oldValue: "Hello", newValue: undefined }); 41 | }} 42 | > 43 | Hello World 44 |
45 | `); 46 | 47 | assert.equal(element.textContent?.trim(), "Hello World"); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/templating/src/lib/elements/value.element.ts: -------------------------------------------------------------------------------- 1 | import { attr, element, css, html } from "@joist/element"; 2 | import { JoistValueEvent } from "../events.js"; 3 | import { JExpression } from "../expression.js"; 4 | 5 | declare global { 6 | interface HTMLElementTagNameMap { 7 | "j-val": JoistValueElement; 8 | } 9 | } 10 | 11 | @element({ 12 | tagName: "j-val", 13 | // prettier-ignore 14 | shadowDom: [css`:host{display: contents;}`, html``], 15 | }) 16 | export class JoistValueElement extends HTMLElement { 17 | @attr() 18 | accessor bind = ""; 19 | 20 | connectedCallback(): void { 21 | const token = new JExpression(this.bind); 22 | 23 | this.dispatchEvent( 24 | new JoistValueEvent(token, (value) => { 25 | const valueToWrite = token.evaluate(value.newValue); 26 | 27 | if ( 28 | valueToWrite !== null && 29 | valueToWrite !== undefined && 30 | this.textContent !== valueToWrite 31 | ) { 32 | this.textContent = String(valueToWrite); 33 | } 34 | }), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/templating/src/lib/events.ts: -------------------------------------------------------------------------------- 1 | import type { Change } from "@joist/observable"; 2 | 3 | import type { JExpression } from "./expression.js"; 4 | 5 | declare global { 6 | interface HTMLElementEventMap { 7 | "joist::value": JoistValueEvent; 8 | } 9 | } 10 | 11 | export interface BindChange extends Change { 12 | alwaysUpdate?: boolean; 13 | firstChange?: boolean; 14 | } 15 | 16 | export class JoistValueEvent extends Event { 17 | readonly expression: JExpression; 18 | readonly update: (value: BindChange) => void; 19 | 20 | constructor(expression: JExpression, update: (value: BindChange) => void) { 21 | super("joist::value", { bubbles: true, composed: true }); 22 | 23 | this.expression = expression; 24 | this.update = update; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/templating/src/lib/expression.ts: -------------------------------------------------------------------------------- 1 | type ComparisonOperator = "==" | "!=" | ">" | "<"; 2 | 3 | interface TokenParts { 4 | path: string[]; 5 | value?: string; 6 | operator?: ComparisonOperator; 7 | } 8 | 9 | /** 10 | * JExpression represents a token that can be used to extract and compare values from objects. 11 | * 12 | * Supported operators: 13 | * - `==` : Equality comparison (e.g., "status==active") 14 | * - `!=` : Inequality comparison (e.g., "status!=active") 15 | * - `>` : Greater than comparison (e.g., "count>5") 16 | * - `<` : Less than comparison (e.g., "count<10") 17 | * 18 | * Examples: 19 | * ```typescript 20 | * // Basic path access 21 | * new JExpression("user.name").readTokenValueFrom({ user: { name: "John" } }) // "John" 22 | * 23 | * // Equality comparison 24 | * new JExpression("status == active").readTokenValueFrom({ status: "active" }) // true 25 | * 26 | * // Inequality comparison 27 | * new JExpression("status != active").readTokenValueFrom({ status: "inactive" }) // true 28 | * 29 | * // Greater than comparison 30 | * new JExpression("count > 5").readTokenValueFrom({ count: 10 }) // true 31 | * 32 | * // Less than comparison 33 | * new JExpression("count < 10").readTokenValueFrom({ count: 5 }) // true 34 | * 35 | * // With negation 36 | * new JExpression("!status == active").readTokenValueFrom({ status: "inactive" }) // true 37 | * 38 | * // Nested paths 39 | * new JExpression("user.score > 100").readTokenValueFrom({ user: { score: 150 } }) // true 40 | * ``` 41 | */ 42 | export class JExpression { 43 | /** The raw token string as provided to the constructor */ 44 | rawToken: string; 45 | /** Whether the token is negated (starts with '!') */ 46 | isNegated = false; 47 | /** The first part of the path (before the first dot) */ 48 | bindTo: string; 49 | /** The remaining parts of the path (after the first dot) */ 50 | path: string[] = []; 51 | /** The value to compare against for equality (==) */ 52 | equalsValue: string | undefined; 53 | /** The value to compare against for inequality (!=) */ 54 | notEqualsValue: string | undefined; 55 | /** The value to compare against for greater than (>) */ 56 | gtValue: string | undefined; 57 | /** The value to compare against for less than (<) */ 58 | ltValue: string | undefined; 59 | 60 | /** 61 | * Creates a new JExpression instance. 62 | * @param rawToken - The token string to parse. Can include operators (==, !=, >, <) and negation (!) 63 | */ 64 | constructor(rawToken: string) { 65 | this.rawToken = rawToken; 66 | this.isNegated = this.rawToken.startsWith("!"); 67 | 68 | const { path, value, operator } = this.#parseToken(); 69 | this.path = path; 70 | this.bindTo = this.path.shift() ?? ""; 71 | this.bindTo = this.bindTo.replaceAll("!", ""); 72 | 73 | // Set the appropriate comparison value based on the operator 74 | switch (operator) { 75 | case "==": 76 | this.equalsValue = value; 77 | break; 78 | case "!=": 79 | this.notEqualsValue = value; 80 | break; 81 | case ">": 82 | this.gtValue = value; 83 | break; 84 | case "<": 85 | this.ltValue = value; 86 | break; 87 | } 88 | } 89 | 90 | /** 91 | * Reads a value from the provided object using the token's path and performs any comparison. 92 | * @param value - The object to read from 93 | * @returns The value at the path, or the result of the comparison if an operator is present 94 | * @template T - The expected return type 95 | */ 96 | evaluate(value: unknown): T { 97 | if (typeof value !== "object" && typeof value !== "string") { 98 | return value as T; 99 | } 100 | 101 | const pathValue = this.#getValueAtPath(value); 102 | 103 | return this.#performComparison(pathValue) as T; 104 | } 105 | 106 | /** 107 | * Parses the raw token into its components. 108 | * @returns An object containing the path parts and any comparison operator/value 109 | */ 110 | #parseToken(): TokenParts { 111 | const operators: ComparisonOperator[] = ["==", "!=", ">", "<"]; 112 | 113 | for (const operator of operators) { 114 | if (this.rawToken.includes(operator)) { 115 | const [tokenPart, value] = this.rawToken.split(operator).map((part) => part.trim()); 116 | return { 117 | path: tokenPart.split("."), 118 | value, 119 | operator, 120 | }; 121 | } 122 | } 123 | 124 | return { 125 | path: this.rawToken.split("."), 126 | }; 127 | } 128 | 129 | /** 130 | * Gets the value at the token's path in the provided object. 131 | * @param value - The object to read from 132 | * @returns The value at the path, or undefined if the path doesn't exist 133 | */ 134 | #getValueAtPath(value: unknown): unknown { 135 | if (value === null || value === undefined) { 136 | return value; 137 | } 138 | 139 | if (!this.path.length) { 140 | return value; 141 | } 142 | 143 | let pointer: any = value; 144 | 145 | for (const part of this.path) { 146 | pointer = pointer?.[part]; 147 | if (pointer === undefined) { 148 | break; 149 | } 150 | } 151 | 152 | return pointer; 153 | } 154 | 155 | /** 156 | * Performs the comparison operation if an operator is present. 157 | * @param value - The value to compare 158 | * @returns The result of the comparison, or the original value if no operator is present 159 | */ 160 | #performComparison(value: unknown): boolean | unknown { 161 | if (this.equalsValue !== undefined) { 162 | return String(value) === this.equalsValue; 163 | } 164 | 165 | if (this.notEqualsValue !== undefined) { 166 | return String(value) !== this.notEqualsValue; 167 | } 168 | 169 | if (this.gtValue !== undefined) { 170 | return Number(value) > Number(this.gtValue); 171 | } 172 | 173 | if (this.ltValue !== undefined) { 174 | return Number(value) < Number(this.ltValue); 175 | } 176 | 177 | return value; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /packages/templating/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "target", 6 | "isolatedDeclarations": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/templating/wtr.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | rootDir: "../../", 3 | nodeResolve: { 4 | exportConditions: ["production"], 5 | }, 6 | files: "target/**/*.test.js", 7 | port: 9878, 8 | }; 9 | -------------------------------------------------------------------------------- /publish_minor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm version minor 4 | npm version minor -w packages/ 5 | npm publish -w packages/ --tag latest 6 | git add -A && git commit -m"$(node -p "require('./package.json').version")" -------------------------------------------------------------------------------- /publish_next.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm version prerelease --preid next 4 | npm version prerelease -w packages/ --preid next 5 | npm publish -w packages/ --tag next 6 | git add -A && git commit -m"$(node -p "require('./package.json').version")" -------------------------------------------------------------------------------- /publish_patch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm version patch 4 | npm version patch -w packages/ 5 | npm publish -w packages/ --tag latest 6 | git add -A && git commit -m"$(node -p "require('./package.json').version")" -------------------------------------------------------------------------------- /publish_rc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | npm version prerelease --preid rc 4 | npm version prerelease -w packages/ --preid rc 5 | npm publish -w packages/ --tag rc 6 | git add -A && git commit -m"$(node -p "require('./package.json').version")" -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "groupName": "@web/test-runner packages", 6 | "packagePatterns": ["^@web"], 7 | "enabled": true 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | "incremental": true /* Enable incremental compilation */, 7 | "target": "ES2022" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "NodeNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "ES2022", 11 | "ESNext.decorators", 12 | "DOM", 13 | "DOM.Iterable" 14 | ] /* Specify library files to be included in the compilation. */, 15 | // "allowJs": true, /* Allow javascript files to be compiled. */ 16 | // "checkJs": true, /* Report errors in .js files. */ 17 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 18 | "declaration": true /* Generates corresponding '.d.ts' file. */, 19 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 20 | "sourceMap": true /* Generates corresponding '.map' file. */, 21 | // "outFile": "./", /* Concatenate and emit output to single file. */ 22 | // "outDir": "./", /* Redirect output structure to the directory. */ 23 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 24 | // "composite": true, /* Enable project compilation */ 25 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 26 | "removeComments": true /* Do not emit comments to output. */, 27 | // "noEmit": true, /* Do not emit outputs. */ 28 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 30 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 31 | 32 | /* Strict Type-Checking Options */ 33 | "strict": true /* Enable all strict type-checking options. */, 34 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 35 | // "strictNullChecks": true, /* Enable strict null checks. */ 36 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 37 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 38 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 39 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 40 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 41 | 42 | /* Additional Checks */ 43 | "noUnusedLocals": true /* Report errors on unused locals. */, 44 | "noUnusedParameters": true /* Report errors on unused parameters. */, 45 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 46 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 47 | 48 | /* Module Resolution Options */ 49 | "moduleResolution": "nodenext" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 50 | // "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 51 | // "paths": {} /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 52 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 53 | // "typeRoots": [], /* List of folders to include type definitions from. */ 54 | // "types": [], /* Type declaration files to be included in compilation. */ 55 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 56 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 57 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 58 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 59 | 60 | /* Source Map Options */ 61 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 62 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 63 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 64 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 65 | 66 | /* Experimental Options */ 67 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 68 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 69 | 70 | /* Advanced Options */ 71 | "skipLibCheck": true /* Skip type checking of declaration files. */, 72 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 73 | } 74 | } 75 | --------------------------------------------------------------------------------