├── .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 | [](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 |
48 |
49 | ()
50 |
51 |
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 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
43 |
44 |
45 |
46 |
47 |
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 |
64 |
65 |
66 |
67 | No todos yet!
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
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 |
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 |
44 | !
45 |
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 |
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 |
22 |
23 |
24 |
25 |
26 |
27 |
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