├── .github
└── workflows
│ └── playwright.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── example
├── example.spec.js
├── header-anchor.html
├── header-anchor.js
└── index.html
├── logo.png
├── package-lock.json
├── package.json
├── playwright.config.js
├── scripts
└── export-components.js
└── tram-deco.js
/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Tram-Deco Playwright Tests
2 |
3 | on: push
4 |
5 | jobs:
6 | test:
7 | timeout-minutes: 15
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-node@v4
12 | with:
13 | node-version: lts/*
14 | - name: Install dependencies
15 | run: npm ci
16 | - name: Install Playwright Browsers
17 | run: npx playwright install --with-deps
18 | - name: Run Playwright tests
19 | run: npm run test:ci
20 | - uses: actions/upload-artifact@v4
21 | if: always()
22 | with:
23 | name: playwright-report
24 | path: playwright-report/
25 | retention-days: 30
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency Directory
2 | node_modules
3 |
4 | # minified output
5 | *.min.js
6 |
7 | # playwrite
8 | /test-results/
9 | /playwright-report/
10 | /blob-report/
11 | /playwright/.cache/
12 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "printWidth": 120,
5 | "proseWrap": "always",
6 | "overrides": [
7 | {
8 | "files": "*.md",
9 | "options": {
10 | "tabWidth": 2,
11 | "useTabs": false
12 | }
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Jesse Jurman
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tram-Deco
2 |
3 |
4 |
5 | _Declarative Custom Elements using native Web Component APIs and specs._
6 |
7 | Tram-Deco provides a more elegant interface for building Web Components, that remains as close as possible to the
8 | existing browser APIs. Tram-Deco is a experiment to understand what a declarative interface for building Web Components
9 | might look like, without the addition of APIs that don't already exist.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | ## Example
20 |
21 | ```html
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
64 |
65 |
66 |
67 |
68 |
71 |
72 |
73 |
74 |
Introduction
75 |
76 | This is some introductory content
77 |
78 |
More Details
79 |
80 | If you want to read more, checkout the README.
81 | ```
82 |
83 | [Live on Codepen](https://codepen.io/JRJurman/pen/RwXPqEe)
84 |
85 | ## How to use
86 |
87 | There are two ways to use Tram-Deco in your project - you can either have component definitions in your served HTML
88 | template (in template tags), or you can export the components as part of a build step to be imported with script tags.
89 |
90 | ### Template Component Definitions
91 |
92 | If you don't want a build step, or are just building components for a dedicated static page, you can do the following to
93 | write component definitions in your main template:
94 |
95 | Include the Tram-Deco library (you can point to either `tram-deco.js` or `tram-deco.min.js`)
96 |
97 | ```html
98 |
99 | ```
100 |
101 | Create a template tag with your component definitions, and then use Tram-Deco to process that template
102 |
103 | ```html
104 |
105 |
106 |
107 |
108 |
111 | ```
112 |
113 | ### Export JS Definition
114 |
115 | If you want to export your component definition, to be used in other projects, or to organize the components in
116 | different files, you can do the following:
117 |
118 | Create a component definition file (`.html`) - this can include as many top-level component definitions as you'd like.
119 |
120 | ```html
121 |
122 |
123 |
124 |
125 |
126 |
127 | ```
128 |
129 | Run the following command in the command line, or as part of a build step:
130 |
131 | ```sh
132 | npx tram-deco export-components my-counter.html
133 | ```
134 |
135 | This will create a JS file that can be imported using a standard script tag:
136 |
137 | ```html
138 |
205 |
206 |
207 |
208 |
209 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
225 |
226 |
227 |
234 |
235 |
236 |
237 |
240 |
241 | Tram-Deco
242 | ```
243 |
244 | [Live on Codepen](https://codepen.io/JRJurman/pen/RwmbMmg)
245 |
246 | ## Motivation
247 |
248 | Tram-Deco was written to showcase a potential implementation of Declarative Custom Elements that could be trivially
249 | adopted by browser implementers. While many alternatives exist, most include new custom APIs, behavior, or syntax that
250 | would necessitate discussions, deliberations, and implementation before making progress on the true goal.
251 |
252 | Tram-Deco strives to be as close to existing APIs as possible, so that the path to browser implementation is as direct
253 | as possible. While many libraries exist to make Web-Component creation easier and more elegant, this library exclusively
254 | highlights how we can leverage existing APIs to get to Declarative Custom Elements.
255 |
256 | ## contributions / discussions
257 |
258 | If you think this is useful or interesting, I'd love to hear your thoughts! Feel free to
259 | [reach out to me on mastodon](https://fosstodon.org/@jrjurman), or join the
260 | [Tram-One discord](https://discord.gg/dpBXAQC).
261 |
--------------------------------------------------------------------------------
/example/example.spec.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | const { test, expect } = require('@playwright/test');
3 | const path = require('path');
4 |
5 | const getTextContent = async (element) => {
6 | return await element.evaluate((el) => el.textContent);
7 | };
8 |
9 | test.describe('Tram-Deco Example Components', () => {
10 | test('should validate all Tram-Deco APIs and Use Cases', async ({ page }) => {
11 | // Construct the absolute file path and use the file:// protocol
12 | const filePath = path.resolve(__dirname, '../example/index.html');
13 | await page.goto(`file://${filePath}`);
14 |
15 | // validate that the document title is set
16 | await expect(page).toHaveTitle('Tram-Deco is Cool!');
17 |
18 | // validate that the title shadowDOM is rendered as expected
19 | const customTitle = page.locator('custom-title');
20 | await expect(customTitle.locator('h1')).toBeVisible();
21 | const renderedText = await getTextContent(customTitle);
22 | expect(renderedText).toBe('Tram-Deco is Cool!');
23 |
24 | // validate that the callout-alert can be collapsed and expanded
25 | const calloutAlert = page.locator('callout-alert');
26 | await expect(calloutAlert).toHaveAttribute('collapsed', '');
27 | await expect(calloutAlert.locator('button')).toHaveText('expand');
28 | await expect(calloutAlert.locator('#content')).not.toBeVisible();
29 | await calloutAlert.locator('button').click();
30 | await expect(calloutAlert.locator('button')).toHaveText('collapse');
31 | await expect(calloutAlert.locator('#content')).toBeVisible();
32 | await expect(calloutAlert).not.toHaveAttribute('collapsed', '');
33 |
34 | // validate that the individual counters can be incremented
35 | const counterA = page.locator('my-counter#a');
36 | await expect(counterA).toHaveAttribute('count', '0');
37 | await expect(counterA.locator('button')).toHaveText('Counter: 0');
38 | await counterA.click();
39 | await expect(counterA).toHaveAttribute('count', '1');
40 | await expect(counterA.locator('button')).toHaveText('Counter: 1');
41 |
42 | const counterB = page.locator('my-counter#b');
43 | await expect(counterB).toHaveAttribute('count', '12');
44 | await expect(counterB.locator('button')).toHaveText('Counter: 12');
45 |
46 | // validate that button that implements a shadow DOM from a parent with none works as expected
47 | const removableButton = page.locator('red-removable-button#r');
48 | await expect(removableButton).toBeVisible();
49 | await removableButton.click();
50 | await expect(removableButton).not.toBeVisible();
51 |
52 | // validate that extended counters with different shadow DOM work as expected
53 | const redCounter = page.locator('my-red-counter#d');
54 | await expect(redCounter).toHaveAttribute('count', '10');
55 | await redCounter.click();
56 | await expect(redCounter).toHaveAttribute('count', '11');
57 |
58 | // validate that extended counters with nothing different work as expected
59 | const copiedCounter = page.locator('my-copied-counter#c');
60 | await expect(copiedCounter).toHaveAttribute('count', '15');
61 | await copiedCounter.click();
62 | await expect(copiedCounter).toHaveAttribute('count', '16');
63 |
64 | // validate that extended counters with different callbacks work as expected
65 | const decrementingCounter = page.locator('my-decrementing-counter#e');
66 | await expect(decrementingCounter).toHaveAttribute('count', '5');
67 | await decrementingCounter.click();
68 | await expect(decrementingCounter).toHaveAttribute('count', '4');
69 |
70 | // validate that exported components work as expected
71 | const introAnchor = page.locator('header-anchor#introduction');
72 | await expect(introAnchor.locator('a')).toHaveAttribute('href', '#introduction');
73 | const detailsAnchor = page.locator('header-anchor#more-details');
74 | await expect(detailsAnchor.locator('a')).toHaveAttribute('href', '#more-details');
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/example/header-anchor.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
18 |
19 |
20 |
21 |
22 |
27 |
33 |
34 |
--------------------------------------------------------------------------------
/example/header-anchor.js:
--------------------------------------------------------------------------------
1 |
2 | (() => {
3 | class TramDeco{static processTemplate(e){[...e.content.children].forEach(e=>{TramDeco.define(e)})}static define(templateElement){const tagName=templateElement.tagName.toLowerCase();class BaseTDElement extends HTMLElement{constructor(e){var t;super(),e&&(this.attachShadow(e),(t=document.createRange()).selectNodeContents(e),this.shadowRoot.append(t.cloneContents()))}}const parentClassName=templateElement.getAttribute("td-extends"),parentClass=customElements.get(parentClassName)||BaseTDElement,modifiedConstructor=templateElement.querySelector('script[td-method="constructor"]');class TDElement extends parentClass{constructor(overrideShadowRoot){super(overrideShadowRoot||templateElement.shadowRoot),eval(modifiedConstructor?.textContent||"")}}templateElement.querySelectorAll("script[td-method]").forEach(lifecycleScript=>{const methodName=lifecycleScript.getAttribute("td-method");TDElement.prototype[methodName]=function(){eval(lifecycleScript.textContent)}}),templateElement.querySelectorAll("script[td-property]").forEach(propertyScript=>{const propertyName=propertyScript.getAttribute("td-property");Object.defineProperty(TDElement,propertyName,{get:function(){return eval(propertyScript.textContent)}})}),customElements.define(tagName,TDElement)}}
4 |
5 | const importTemplate = document.createElement('template')
6 | importTemplate.setHTMLUnsafe(`
7 |
8 |
23 |
24 |
25 |
26 |
27 |
32 |
38 |
39 | `)
40 |
41 | TramDeco.processTemplate(importTemplate);
42 | })()
43 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
15 |
16 |
17 |