├── .github └── workflows │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bun.lockb ├── bunfig.toml ├── dist ├── van-element.browser.js ├── van-element.js └── van-element.umd.cjs ├── docs ├── .vitepress │ ├── config.ts │ └── theme │ │ ├── custom.css │ │ └── index.js ├── advanced │ ├── shared-state.md │ ├── slots.md │ └── styling.md ├── components.ts ├── examples.md ├── index.md ├── intro │ ├── get-started.md │ ├── installation.md │ └── tutorial.md ├── learn │ ├── attributes.md │ ├── lifecycle.md │ ├── overview.md │ └── shadow-options.md └── public │ ├── favicon.png │ ├── logo.color.svg │ ├── logo.dark.svg │ └── logo.svg ├── index.html ├── package.json ├── scripts └── build.ts ├── src ├── showcase.ts ├── van-element.d.ts └── van-element.js ├── tests ├── internals.test.ts ├── light-dom.test.ts └── setup.ts ├── tsconfig.json ├── types └── van-element.d.ts └── vite.config.ts /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: oven-sh/setup-bun@v2 15 | - run: bun install 16 | - run: bun test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs/.vitepress/cache 3 | docs/.vitepress/dist -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Atmos4 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 | # Van Element - WebComponents with VanJS 2 | 3 | A simple function to create VanJS web components. [See it in action](https://codepen.io/atmos4/pen/ZEPEvvB). 4 | 5 | ## Documentation 6 | 7 | https://van-element.pages.dev/. 8 | 9 | ## Usage 10 | 11 | ```javascript 12 | import van from "vanjs-core"; 13 | import { define } from "vanjs-element"; 14 | 15 | const { button, div, slot } = van.tags; 16 | 17 | define("custom-counter", () => { 18 | const counter = van.state(0); 19 | return div( 20 | slot(), 21 | counter, 22 | button({ onclick: () => ++counter.val }, "+"), 23 | button({ onclick: () => --counter.val }, "-") 24 | ); 25 | }); 26 | ``` 27 | 28 | In your HTML: 29 | 30 | ```html 31 | ❤️ 32 | 33 | 👌 34 | ``` 35 | 36 | ## Why use this 37 | 38 | - automatic hydration of VanJS inside your HTML 39 | - reusable components without extra boilerplate 40 | - isolated styles and slots with Web components 41 | - extremely tiny (295B min+gzip) 42 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Atmos4/van-element/c9c52cfddbb5820f7f70d6172b74610104b5908a/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = "./tests/setup.ts" -------------------------------------------------------------------------------- /dist/van-element.browser.js: -------------------------------------------------------------------------------- 1 | window.vanE={define:function(t,e,s={mode:"open"}){window.customElements.define(t,class extends HTMLElement{constructor(){super(),this.a=[]}setAttribute(t,e){super.setAttribute(t,e),this.a[t]&&(this.a[t].val=e)}connectedCallback(){let t;van.add(s?this.attachShadow(s):this,e({attr:(t,e)=>this.a[t]??=van.state(this.getAttribute(t)??e),mount:e=>{let s=t;t=()=>{let t=s?.(),i=e();return()=>{t?.(),i?.()}}},$this:this})),this.d=t?.()}disconnectedCallback(){this.d?.()}})}}; -------------------------------------------------------------------------------- /dist/van-element.js: -------------------------------------------------------------------------------- 1 | import t from"vanjs-core";function e(e,s,i={mode:"open"}){window.customElements.define(e,class extends HTMLElement{constructor(){super(),this.a=[]}setAttribute(t,e){super.setAttribute(t,e),this.a[t]&&(this.a[t].val=e)}connectedCallback(){let e;t.add(i?this.attachShadow(i):this,s({attr:(e,s)=>this.a[e]??=t.state(this.getAttribute(e)??s),mount:t=>{let s=e;e=()=>{let e=s?.(),i=t();return()=>{e?.(),i?.()}}},$this:this})),this.d=e?.()}disconnectedCallback(){this.d?.()}})}export{e as define}; -------------------------------------------------------------------------------- /dist/van-element.umd.cjs: -------------------------------------------------------------------------------- 1 | (function(s,i){typeof exports=="object"&&typeof module<"u"?i(exports,require("vanjs-core")):typeof define=="function"&&define.amd?define(["exports","vanjs-core"],i):(s=typeof globalThis<"u"?globalThis:s||self,i(s.vanE={},s.van))})(this,function(s,i){"use strict";function c(f,h,a={mode:"open"}){window.customElements.define(f,class extends HTMLElement{constructor(){super(),this.a=[]}setAttribute(e,t){super.setAttribute(e,t),this.a[e]&&(this.a[e].val=t)}connectedCallback(){let e;i.add(a?this.attachShadow(a):this,h({attr:(t,n)=>{var d;return(d=this.a)[t]??(d[t]=i.state(this.getAttribute(t)??n))},mount:t=>{let n=e;e=()=>{let d=n==null?void 0:n(),o=t();return()=>{d==null||d(),o==null||o()}}},$this:this})),this.d=e==null?void 0:e()}disconnectedCallback(){var e;(e=this.d)==null||e.call(this)}})}s.define=c,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"})}); 2 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Van Element - Docs", 6 | description: "Documentation for Van Element", 7 | /* prettier-ignore */ 8 | head: [ 9 | ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.color.svg' }], 10 | ['link', { rel: 'icon', type: 'image/png', href: '/favicon.png' }], 11 | ['meta', { name: 'theme-color', content: '#fe3434' }], 12 | ['meta', { name: 'og:type', content: 'website' }], 13 | ['meta', { name: 'og:locale', content: 'en' }], 14 | ['meta', { name: 'og:site_name', content: 'Van Element' }], 15 | ], 16 | themeConfig: { 17 | logo: { 18 | src: "/logo.color.svg", 19 | }, 20 | // https://vitepress.dev/reference/default-theme-config 21 | nav: [ 22 | { text: "Get started", link: "/intro/get-started" }, 23 | { text: "Tutorial", link: "/intro/tutorial" }, 24 | { text: "Learn", link: "/learn/overview" }, 25 | { text: "Examples", link: "/examples" }, 26 | ], 27 | 28 | sidebar: [ 29 | { 30 | text: "Introduction", 31 | base: "/intro/", 32 | items: [ 33 | { text: "Get started", link: "get-started" }, 34 | { text: "Installation", link: "installation" }, 35 | { text: "Tutorial", link: "tutorial" }, 36 | ], 37 | }, 38 | { 39 | text: "Learn", 40 | base: "/learn/", 41 | items: [ 42 | { text: "Overview", link: "overview" }, 43 | { text: "Attributes", link: "attributes" }, 44 | { text: "Lifecycle", link: "lifecycle" }, 45 | { text: "Options", link: "shadow-options" }, 46 | ], 47 | }, 48 | { 49 | text: "Advanced", 50 | base: "/advanced/", 51 | items: [ 52 | { text: "Slots", link: "slots" }, 53 | { text: "Shared state", link: "shared-state" }, 54 | { text: "Styling", link: "styling" }, 55 | ], 56 | }, 57 | { text: "Examples", link: "examples" }, 58 | ], 59 | 60 | socialLinks: [ 61 | { icon: "github", link: "https://github.com/Atmos4/van-element" }, 62 | ], 63 | }, 64 | vue: { 65 | template: { 66 | compilerOptions: { 67 | // All custom elements will be Van Elements 68 | isCustomElement: (tag) => tag.includes("-"), 69 | }, 70 | }, 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand-1: var(--vp-c-red-1); 3 | --vp-c-brand-2: var(--vp-c-red-2); 4 | --vp-c-brand-3: var(--vp-c-red-3); 5 | 6 | --vp-home-hero-name-color: transparent; 7 | --vp-home-hero-name-background: -webkit-linear-gradient( 8 | 120deg, 9 | #ffd341, 10 | #fe3434 100% 11 | ); 12 | } 13 | fieldset { 14 | padding: 0 20px 10px; 15 | border-radius: 8px; 16 | border: 1px solid var(--vp-c-red-1); 17 | } 18 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import DefaultTheme from "vitepress/theme"; 2 | import "./custom.css"; 3 | 4 | export default { 5 | extends: DefaultTheme, 6 | async enhanceApp() { 7 | !import.meta.env.SSR && import("../../components.ts"); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /docs/advanced/shared-state.md: -------------------------------------------------------------------------------- 1 | # How to share state accross Van Elements 2 | 3 | There are a few techniques we can use to share state across different Van Elements 4 | 5 | ## Global state 6 | 7 | The simplest way to share state is to create a `van.state` object in the global scope, and share it between different Van Elements. You can even use [VanX.reactive](https://vanjs.org/x#reactive-object) to create a store. 8 | 9 | Here is a simple example: 10 | 11 | <<< @/components.ts#sharedState 12 | 13 | Now this state can be modified in one place: 14 | 15 | ```html 16 | 17 | ``` 18 | 19 |
20 | Result 21 | 22 |
23 | 24 | And displayed in a different place in the DOM! 25 | 26 | ```html 27 | 28 | ``` 29 | 30 |
31 | Result 32 | 33 |
34 | 35 | One downside of this approach is that the state is truly global. Try to click on another item in the sidebar then back here: the counter shoud still display the same value. 36 | 37 | Usually, it is not a big issue as global state is often suitable. 38 | 39 | ## Local state 40 | 41 | In order to achieve local shared state, we have to implement a _context_, similar to React Context. The context will serve state to its children (provider), which can then read its value (consumers). 42 | 43 | Fortunately, there is a context specification we can use to turn our Van Elements into context providers / consumers. Since it's a spec, it can even allow interaction with other contexts like Lit Context. 44 | 45 | ::: info Note 46 | I have not worked extensively on this solution since I don't think it is as useful. All I have is this [very raw CodePen](https://codepen.io/atmos4/pen/NWJNVNz) and some draft code on my computer somewhere. 47 | 48 | If this is of interest to you, feel free to create an issue and I will polish whatever code I have to turn it into a reusable package! 49 | ::: 50 | -------------------------------------------------------------------------------- /docs/advanced/slots.md: -------------------------------------------------------------------------------- 1 | # Slots 2 | 3 | ::: info 4 | 5 | You can only used slots if the Shadow DOM is enabled. 6 | 7 | ::: 8 | 9 | Slots are like children in VanJS, but for Web Components. 10 | 11 | <<< @/components.ts#slots {javascript} 12 | 13 | ```html 14 | Robert and Marie 15 | ``` 16 | 17 |
18 | Result 19 | 20 | Robert and Marie 21 | 22 |
23 | 24 | Slots can have names, which allows you to customize many different places in the Van Element. 25 | 26 | <<< @/components.ts#slotsNames {javascript} 27 | 28 | ```html 29 | 30 | The title 31 |

The paragraph

32 |
33 | ``` 34 | 35 |
36 | Result 37 | 38 | The title 39 |

The paragraph

40 |
41 |
42 | 43 | You will find more examples in the [Examples](../examples) section. 44 | -------------------------------------------------------------------------------- /docs/advanced/styling.md: -------------------------------------------------------------------------------- 1 | # Styling 2 | 3 | There are several ways to style a Van Element. 4 | 5 | ::: warning 6 | 7 | The Shadow DOM gets in the way very often, and is even one of the main reasons Web Components face skepticism from the Web development community. 8 | 9 | If you don't want isolated styles, you can [use Van Elements without the Shadow DOM](../learn/shadow-options#disable-shadow-dom)! 10 | 11 | ::: 12 | 13 | ## Inline styles 14 | 15 | The simplest way to style Van Elements is inline styles 16 | 17 | <<< @/components.ts#inlineStyles 18 | 19 | ```html 20 | 21 | ``` 22 | 23 |
24 | Result 25 | 26 |
27 | 28 | Inline styles are often frowned upon for good reasons. However, in an isolated environment like the Shadow DOM, they can work really well if complex styling is not needed. 29 | 30 | ## `style` tag 31 | 32 | If you want more complex styling, using a `style` tag is a very good option. The reason it works is because the Shadow DOM will isolate these styles from the rest of the DOM so it won't leak out! 33 | 34 | ::: tip 35 | 36 | You can use the CSS selector `::slotted` to apply specific styles to the slotted element. 37 | 38 | ::: 39 | 40 | <<< @/components.ts#styleTag 41 | 42 | ```html 43 |

Paragraph in the slot

44 |

Paragraph in normal DOM

45 | ``` 46 | 47 |
48 | Result 49 |

Paragraph in the slot

50 |

Paragraph in normal DOM

51 |
52 | 53 | ## Adopted stylesheets 54 | 55 | This method revolves around creating a `CSSStyleSheet` and add it to the Shadow Root. 56 | 57 | <<< @/components.ts#adoptedStyle 58 | 59 | ```html 60 | Adopted styles! 61 | ``` 62 | 63 |
64 | Result 65 | Adopted styles! 66 |
67 | 68 | ::: warning 69 | 70 | This method currently lacks testing and support. It is also a bit more awkward to use, so the `style` tag method is preferred. 71 | 72 | However it has benefits, the major one being that you can _merge two style sheets_ without conflicts or _share them between components_. 73 | 74 | If you would like more support for this, feel free to create an issue or even to contribute 🙂 75 | 76 | ::: 77 | -------------------------------------------------------------------------------- /docs/components.ts: -------------------------------------------------------------------------------- 1 | import van, { type State } from "vanjs-core"; 2 | import { define } from "../src/van-element"; 3 | 4 | const { button, dialog, div, h2, i, input, p, pre, slot, span, style } = 5 | van.tags; 6 | 7 | // Tutorial 8 | 9 | // #region isolatedStyles 10 | define("hello-world", ({ attr }) => { 11 | const color = attr("color", "red"); 12 | const size = attr("size", "20px"); 13 | return [ 14 | // Styles won't leak out! // [!code highlight:2] 15 | style(() => `*{color:${color.val};font-size:${size.val}}`), 16 | span(slot()), 17 | ]; 18 | }); 19 | // #endregion isolatedStyles 20 | 21 | // #region tuto4 22 | const RangePicker = (min: number, max: number, value: State) => 23 | input({ 24 | type: "range", 25 | min, 26 | max, 27 | value, 28 | oninput: (e) => (value.val = e.target.value), 29 | }); 30 | 31 | define("tutorial-wrapper", () => { 32 | const color = van.state(0); 33 | const size = van.state(20); 34 | return div( 35 | div("Hue: ", RangePicker(0, 360, color), () => ` ${color.val}deg`), 36 | div("Size: ", RangePicker(20, 40, size), () => ` ${size.val / 20}em`), 37 | p( 38 | van.tags["hello-world"]( 39 | { 40 | color: () => `hsl(${color.val} 100% 50%)`, 41 | size: () => `${size.val / 20}em`, 42 | }, 43 | slot() 44 | ) 45 | ) 46 | ); 47 | }); 48 | // #endregion tuto4 49 | 50 | // #region tuto5 51 | define("computed-size", ({ attr }) => { 52 | const color = attr("color", "red"); 53 | const size = attr("size", "20px"); 54 | const dom = slot(); 55 | return [ 56 | style( 57 | () => ` 58 | * { 59 | color: ${color.val}; 60 | font-size: ${size.val}; 61 | } 62 | ` 63 | ), 64 | span(dom), 65 | window.getComputedStyle(dom, null).fontSize, 66 | ]; 67 | }); 68 | // #endregion tuto5 69 | 70 | // #region tuto5fixed 71 | define("computed-size-fixed", ({ attr, mount }) => { 72 | const color = attr("color", "red"); 73 | const size = attr("size", "20px"); 74 | const dom = slot(); 75 | const computedFontSize = van.state(""); // [!code ++:4] 76 | mount(() => { 77 | computedFontSize.val = window.getComputedStyle(dom, null).fontSize; 78 | }); 79 | return [ 80 | style( 81 | () => ` 82 | * { 83 | color: ${color.val}; 84 | font-size: ${size.val}; 85 | } 86 | ` 87 | ), 88 | span(dom), 89 | computedFontSize, 90 | ]; 91 | }); 92 | // #endregion tuto5fixed 93 | 94 | // #region selfReference 95 | define("final-element", ({ attr, mount, $this }) => { 96 | if ($this.childElementCount || !$this.innerHTML.trim()) 97 | return span({ style: "color:red" }, "ERROR - only text allowed"); 98 | const color = attr("color", "red"); 99 | const size = attr("size", "20px"); 100 | const dom = slot(); 101 | const computedFontSize = van.state(""); 102 | mount(() => { 103 | computedFontSize.val = window.getComputedStyle(dom, null).fontSize; 104 | }); 105 | return [ 106 | style( 107 | () => ` 108 | * { 109 | color: ${color.val}; 110 | font-size: ${size.val}; 111 | } 112 | ` 113 | ), 114 | span(dom), 115 | computedFontSize, 116 | ]; 117 | }); 118 | // #endregion selfReference 119 | 120 | // #region getstarted 121 | define("custom-element", () => 122 | p( 123 | "I am a Van Element 🎉 ", 124 | button({ onclick: () => alert("Hello from VanJS 🍦") }, "Click me") 125 | ) 126 | ); 127 | // #endregion getstarted 128 | 129 | // #region shadowButton 130 | define("shadow-button", () => button("Shadow DOM")); 131 | // #endregion shadowButton 132 | 133 | // #region basic 134 | define("custom-counter", () => { 135 | const counter = van.state(0); 136 | return p( 137 | div("Counter: ", counter), 138 | button({ onclick: () => ++counter.val }, "+"), 139 | button({ onclick: () => --counter.val }, "-"), 140 | button({ onclick: () => (counter.val = 0) }, "Reset") 141 | ); 142 | }); 143 | // #endregion basic 144 | 145 | // VanJS minigame! 146 | // #region minigame 147 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 148 | 149 | const Run = (sleepMs: number, icon: string) => { 150 | const steps = van.state(0); 151 | (async () => { 152 | for (; steps.val < 40; ++steps.val) await sleep(sleepMs); 153 | })(); 154 | return pre( 155 | () => `${" ".repeat(40 - steps.val)}${icon}${"_".repeat(steps.val)}` 156 | ); 157 | }; 158 | 159 | const Hello = () => { 160 | const dom = div(); 161 | return p( 162 | dom, 163 | button({ onclick: () => van.add(dom, Run(2000, "🐌")) }, "Hello 🐌"), 164 | button({ onclick: () => van.add(dom, Run(500, "🐢")) }, "Hello 🐢"), 165 | button({ onclick: () => van.add(dom, Run(100, "🚶‍♂️")) }, "Hello 🚶‍♂️"), 166 | button({ onclick: () => van.add(dom, Run(10, "🏎️")) }, "Hello 🏎️"), 167 | button({ onclick: () => van.add(dom, Run(2, "🚀")) }, "Hello 🚀") 168 | ); 169 | }; 170 | // #endregion minigame 171 | 172 | // #region minigameBind 173 | define("vanjs-game", Hello); 174 | // #endregion minigameBind 175 | 176 | // #region fontPreview 177 | 178 | // #endregion fontPreview 179 | 180 | // #region attributes 181 | define("attributes-demo", ({ attr }) => 182 | p( 183 | "Hello ", 184 | attr( 185 | "name", // attribute name // [!code highlight] 186 | "Max" // optional default value // [!code highlight] 187 | ) 188 | ) 189 | ); 190 | // #endregion attributes 191 | 192 | // #region composition 193 | define("my-parent", () => 194 | p( 195 | van.tags["attributes-demo"]({ name: "John" }) // Injected attributes // [!code highlight] 196 | ) 197 | ); 198 | // #endregion composition 199 | 200 | // #region observed 201 | define("reactive-attribute", ({ attr }) => p("Hello ", attr("name"))); 202 | 203 | define("attribute-parent", () => { 204 | const name = van.state("John"); 205 | return p( 206 | "Type your name: ", 207 | input({ 208 | type: "text", 209 | value: name, 210 | oninput: (e) => (name.val = e.target.value), 211 | }), 212 | van.tags["reactive-attribute"]({ name }) // nested Van Element // [!code highlight] 213 | ); 214 | }); 215 | // #endregion observed 216 | 217 | // #region slots 218 | define("slot-demo", () => p("Hello ", slot())); 219 | // #endregion slots 220 | 221 | // #region slotsNames 222 | define("slot-names", () => 223 | div( 224 | h2(slot({ name: "title" })), // Named slot // [!code highlight] 225 | i("The Van Element 🍦"), 226 | slot() // Default slot // [!code highlight] 227 | ) 228 | ); 229 | // #endregion slotsNames 230 | 231 | // #region mountExample 232 | define("connect-example", () => { 233 | const dom = slot(); 234 | return div(dom, pre("Items in slot - ", dom.assignedElements().length)); 235 | }); 236 | // #endregion mountExample 237 | 238 | // #region mountShowcase 239 | define("mount-showcase", ({ mount }) => { 240 | const dom = slot(); 241 | const slotCount = van.state(dom.assignedElements().length); 242 | mount(() => { 243 | slotCount.val = dom.assignedElements().length; 244 | }); 245 | return div(dom, pre("Items in slot - ", slotCount)); 246 | }); 247 | // #endregion mountShowcase 248 | 249 | // Styles 250 | 251 | // #region inlineStyles 252 | define("inline-styles", () => p({ style: "color:red" }, "I am red")); 253 | // #endregion inlineStyles 254 | 255 | // #region styleTag 256 | define("style-tag", () => [ 257 | style(` 258 | p { 259 | color: red; 260 | } 261 | ::slotted(p) { 262 | color: orange; 263 | } 264 | `), 265 | slot(), 266 | p("Paragraph in Shadow DOM"), 267 | ]); 268 | // #endregion styleTag 269 | 270 | // #region adoptedStyle 271 | define("adopted-style", ({ $this }) => { 272 | const css = new CSSStyleSheet(); 273 | css.replaceSync(` 274 | * { 275 | color: orange; 276 | } 277 | `); 278 | $this.shadowRoot?.adoptedStyleSheets.push(css); 279 | return p(slot()); 280 | }); 281 | // #endregion adoptedStyle 282 | 283 | // EXAMPLES 284 | 285 | // #region confirmationModal 286 | define("confirmation-modal", ({ attr, $this }) => { 287 | const confirmLabel = attr("confirm"); 288 | const cancelLabel = attr("cancel", "Close"); 289 | const onConfirm = () => { 290 | modal.close(); 291 | $this.dispatchEvent(new Event("submit")); 292 | }; 293 | const modal = dialog( 294 | div({ class: "mainContent" }, slot()), 295 | div( 296 | { class: "actions" }, 297 | button({ onclick: () => modal.close() }, cancelLabel), 298 | () => confirmLabel.val && button({ onclick: onConfirm }, confirmLabel.val) 299 | ) 300 | ); 301 | return [ 302 | slot({ name: "trigger", onclick: () => modal.showModal() }), 303 | modal, 304 | // Some styles 305 | style(` 306 | dialog{ 307 | padding: 2rem; 308 | } 309 | dialog::backdrop{ 310 | backdrop-filter:blur(5px); 311 | } 312 | .mainContent{ 313 | text-align: center; 314 | } 315 | .actions{ 316 | display: flex; 317 | justify-content: space-around; 318 | } 319 | button{ 320 | border:1px solid; 321 | font: inherit; 322 | padding: .5rem 1rem; 323 | background: transparent; 324 | cursor: pointer; 325 | }`), 326 | ]; 327 | }); 328 | // #endregion confirmationModal 329 | 330 | // #region sharedState 331 | const sharedState = van.state(0); 332 | 333 | define("increment-state", () => 334 | button({ onclick: () => sharedState.val++ }, "Increment (", sharedState, ")") 335 | ); 336 | 337 | define("display-state", () => 338 | div( 339 | sharedState, 340 | " ", 341 | button({ onclick: () => (sharedState.val = 0) }, "Reset") 342 | ) 343 | ); 344 | // #endregion sharedState 345 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ::: info 4 | 5 | This page is under construction. 6 | 7 | **Contributions are most welcomed! 🙂** 8 | 9 | ::: 10 | 11 | This page will focus on showing more practical examples that reflect real world problems that can find a solution with Van Elements. 12 | 13 | These examples were written with the following philosophy: 14 | 15 | - most problems involving reactive UIs can be solved with VanJS 16 | - custom elements are a good fit for hydrating reactive logic into the DOM 17 | 18 | ::: tip 19 | 20 | Shadow DOM can be useful for isolating specific styles. Parts that need to be exposed can just live in the slots. 21 | 22 | If it gets in the way, just disable it. It is not as big of a deal as you might think. 23 | 24 | ::: 25 | 26 | ## 1. Reusable confirmation modal 27 | 28 | This example illustrates how to create a self-contained confirmation modal. It uses a few techniques: 29 | 30 | - **slots** to populate a custom trigger 31 | - a custom `submit` event that allows to intercept actions from outside the component 32 | - Shadow DOM isolation 33 | 34 | 35 | 36 |

Please confirm

37 |

Are you sure you want to do this?

38 |
39 | 40 | ::: details Code 41 | <<< @/components.ts#confirmationModal {javascript} 42 | ::: 43 | 44 | ```html 45 | 46 | 47 |

Confirmation

48 |

Are you sure you want to do this?

49 |
50 | ``` 51 | 52 | Now, thanks to the power of custom element reusability, we can reuse that confirmation modal anywhere, with custom text and actions. 53 | 54 | 55 | 56 | Tip of the day 57 |

Eat vegetables to stay healthy

58 |
59 | 60 | ```html 61 | 62 | 63 | Tip of the day 64 |

Eat vegetables to stay healthy

65 |
66 | ``` 67 | 68 | ## 2. Normal VanJS code 69 | 70 | Van Element is just a way to hydrate VanJS. So we could simply take VanJS code and bind it to a custom element tag, and Van Element will put it in the DOM for us! 71 | 72 | As an example, let's shamefully take the Hello world program from VanJS's home page 🤫 73 | 74 | ::: details Code 75 | <<< @/components.ts#minigame {typescript} 76 | ::: 77 | 78 | In order to hydrate this into the DOM, we just have to bind that VanJS function to a custom element tag: 79 | 80 | <<< @/components.ts#minigameBind {javascript} 81 | 82 | Now we can just slap that custom element anywhere in our HTML 🎉 83 | 84 | ```html 85 | 86 | ``` 87 | 88 | 89 | 90 | As it is now, this component looks ugly because we did not style it. We have 2 solutions: 91 | 92 | - [Style it](./advanced/styling) within the Shadow DOM. This will make our component truly isolated and reusable. 93 | - [Disable the Shadow DOM](./learn/shadow-options#disable-shadow-dom), and style it with external stylesheets. Our component will depend on those stylesheets and isn't truly reusable anymore, but we can now use our favorite CSS framework to make it look beautiful! 94 | 95 | **Choose whichever option you prefer**. People like to get emotional over the Shadow DOM, but in most cases it's not needed. 96 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: Van Element 7 | tagline: Build reusable VanJS components easily. 8 | actions: 9 | - theme: brand 10 | text: Get Started 11 | link: /intro/get-started 12 | - theme: alt 13 | text: VanJS docs 14 | link: https://vanjs.org/ 15 | image: 16 | src: /logo.color.svg 17 | alt: VitePress 18 | 19 | features: 20 | - icon: 🍦 21 | title: Build with VanJS 22 | details: Reactivity in 1kB. 23 | - icon: ⚙️ 24 | title: The power of custom elements 25 | details: Reusable and framework-agnostic. 26 | - title: Lightweight and simple to use 27 | icon: 🌸 28 | details: One utility method, 300 bytes. 29 | --- 30 | -------------------------------------------------------------------------------- /docs/intro/get-started.md: -------------------------------------------------------------------------------- 1 | # Hello and welcome 👋 2 | 3 | Here you can learn about Van Elements in different ways: 4 | 5 | - Read the following introduction for a brief summary. 6 | - Take a more hands-on approach with [the tutorial](./tutorial) 7 | - Browse [the examples](../examples) for concrete real-world applications 8 | - Dive in [the API overview](../learn/overview) if you like to read 🤓 9 | 10 | ## What is a Van Element 11 | 12 | A Van Element is a [VanJS](https://vanjs.org/) Web Component. You can create one with the `define` method: 13 | 14 | <<< @/components.ts#getstarted {javascript} 15 | 16 | Then this element can be used anywhere in HTML 17 | 18 | ```html 19 | 20 | ``` 21 | 22 |
23 | Result 24 | 25 |
26 | 27 | ## Why Van Element 28 | 29 | [VanJS](https://vanjs.org/) is a fantastic ultra-lightweight option for building reactive UI. However, hydrating VanJS inside HTML can feel a bit awkward. 30 | 31 | A Van Element leverages native custom elements to automatically hydrate HTML with VanJS reactivity. It retains all the [benefits from VanJS](https://vanjs.org/#why-vanjs) with a few extra ones: 32 | 33 | - ### Reusability 34 | 35 | Once defined, Van Elements can be added, removed and reused anywhere in your HTML with a simple custom tag. 36 | 37 | - ### Portability 38 | 39 | Van Elements are standard Web Components that can work with any framework or templating language. You can use them in backend templating or inside frontend libraries like React, Vue or Svelte. 40 | 41 | - ### Isolation 42 | 43 | Thanks to the Shadow DOM, Van Elements benefit from style encapsulation and won't conflict with existing styles or other Web Components. 44 | 45 | - ### Control 46 | 47 | Van Elements can access the [custom element lifecycle](../learn/lifecycle) and manipulate Shadow DOM utilities like [slots](../advanced/slots) to make it easier to build interactive components. 48 | 49 | ## Web Components = 💩? 50 | 51 | > But why would I ever use Web Components? They are so hard to work with, I hate the Shadow DOM. 52 | 53 | The term `Web Components` is not a technical unity like React, but more of a concept regrouping two main APIs: 54 | 55 | - `custom elements`, the central part of Van Elements that enables hydration and lifecycle callbacks. 56 | - the `Shadow DOM`, a DOM and CSS isolation mechanism. 57 | 58 | Because the Shadow DOM isolates styles from the outside, it is very hard to work with when integrating with CSS frameworks, existing design systems or tools like Tailwind. 59 | 60 | Fortunately, **you can use Van Elements [without the Shadow DOM](../learn/shadow-options#disable-shadow-dom) and retain most of its benefits 🔥** 61 | -------------------------------------------------------------------------------- /docs/intro/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | In order to use Van Elements, you will need: 4 | 5 | - Some understanding of Web Components ([Web Components MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).) 6 | - Basic knowledge of VanJS syntax ([VanJS docs](https://vanjs.org/)) 7 | 8 | ### Package manager 9 | 10 | ::: code-group 11 | 12 | ```sh [npm] 13 | $ npm add vanjs-core vanjs-element 14 | ``` 15 | 16 | ```sh [pnpm] 17 | $ pnpm add vanjs-core vanjs-element 18 | ``` 19 | 20 | ```sh [yarn] 21 | $ yarn add vanjs-core vanjs-element 22 | ``` 23 | 24 | ```sh [bun] 25 | $ bun add vanjs-core vanjs-element 26 | ``` 27 | 28 | ::: 29 | 30 | ```ts 31 | import van from "vanjs-core"; 32 | import { define } from "vanjs-element"; 33 | ``` 34 | 35 | ### Browser 36 | 37 | Since Van Element doesn't require a build step, it can be loaded from a CDN or stored in a local file ([download on jsDelivr](https://www.jsdelivr.com/package/npm/vanjs-element)). 38 | 39 | ::: code-group 40 | 41 | ```html [CDN] 42 | 43 | 44 | ``` 45 | 46 | ```html [Local files] 47 | 48 | 49 | ``` 50 | 51 | ```html [Import maps] 52 | 53 | 61 | 67 | ``` 68 | 69 | ::: 70 | 71 | When imported in the global scope, you can use the global object `vanE`. 72 | 73 | ```javascript 74 | vanE.define(...); 75 | ``` 76 | 77 | ::: warning Note: 78 | 79 | Since it uses `window.customElements`, Van Element only works in the browser and should not be used during SSR. Refer to the documentation of your framework to prevent it from defining Van Elements on the server. 80 | 81 | ::: 82 | -------------------------------------------------------------------------------- /docs/intro/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | Before starting, it is recommended that you take the [VanJS tutorial](https://vanjs.org/tutorial) 🙂 4 | 5 | You can follow the tutorial with [this CodePen template](https://codepen.io/pen?template=WNmQwLw), or just read along if you prefer! 6 | 7 | ## First element 8 | 9 | Let's build our first Van Element! It will just be a `span` with inline styles: 10 | 11 | ```js 12 | define("hello-world", () => 13 | span({ style: "color:red;font-size:20px" }, "Hello world!") 14 | ); 15 | ``` 16 | 17 | ```html 18 | My first Van Element: 19 | ``` 20 | 21 |
22 | Result 23 | My first Van Element: Hello world 24 |
25 | 26 | ## Slots 27 | 28 | Let's add children to our Van element. We can use the `slot` for this. 29 | 30 | ```js 31 | const { span, slot } = van.tags; 32 | 33 | define("hello-world", () => 34 | span({ style: "color:red;font-size:20px" }, slot()) 35 | ); 36 | ``` 37 | 38 | ```html 39 | Cool discovery: the slot 40 | ``` 41 | 42 |
43 | Result 44 | Cool discovery: the slot 45 |
46 | 47 | ::: tip 48 | Because they are Web components, Van Elements can use the `slot` tag as a way to inject children HTML elements. [Learn more about slots here!](../advanced/slots) 49 | ::: 50 | 51 | ## Attributes 52 | 53 | It would be nice if we can change `color` and `font-size` from outside the Van Element, right? 54 | 55 | Meet the first property provided by Van Element: `attr()`. It takes an attribute name and an optional default value and returns a VanJS `State` object. 56 | 57 | ```js 58 | define("hello-world", ({ attr }) => { 59 | const color = attr( 60 | "color", // name of the attribute 61 | "red" // default value (optional) 62 | ); 63 | const size = attr("size", 20); 64 | return span( 65 | { style: () => `color:${color.val};font-size:${size.val}` }, 66 | slot() 67 | ); 68 | }); 69 | ``` 70 | 71 | ```html 72 | I can be green 73 | or orange 74 | or red by default 75 | ``` 76 | 77 |
78 | Result 79 | I can be green 80 | or orange 81 | or default 82 |
83 | 84 | ## Isolated styles 85 | 86 | There is another way we can style our content instead of inline styles: by using a `style` tag. 87 | 88 | Our Van Element is isolated in the Shadow DOM, so whatever we write in that inner style won't leak out to the rest of the page! 89 | 90 | <<< @/components.ts#isolatedStyles {javascript} 91 | 92 | ```html 93 | The styles in the normal DOM 94 | or in other Van Elements 95 | won't be affected! 96 | ``` 97 | 98 |
99 | Result 100 | The styles in the normal DOM 101 | or in other Van Elements 102 | won't be affected! 103 |
104 | 105 | ## Reactive Van Elements 106 | 107 | This tutorial is way too static. Let's add a bit of reactivity. 108 | 109 | Something nice about Van Elements is that you can reuse them... inside other Van Elements! 110 | 111 | As an example, let's build some handles for our Van Element: 112 | 113 | <<< @/components.ts#tuto4 {javascript} 114 | 115 | ```html 116 | Color sample 117 | ``` 118 | 119 |
120 | Result 121 | Color sample 122 |
123 | 124 | ## Lifecycle 125 | 126 | Since `em` is not very visual, it would be nice to get the computed `font-size` in pixels. We could use `window.getComputedStyle` for this! Let's try it: 127 | 128 | <<< @/components.ts#tuto5 129 | 130 | ```html 131 | 1.5em
132 | 1.2em 133 | ``` 134 | 135 |
136 | Result 137 | 1.5em
138 | 1.2em 139 |
140 | 141 | That doesn't seem to work 🤔 the reason is that slots only get populated _after_ the component has rendered. 142 | 143 | For this, there is the `mount` hook: it registers a function that only runs when the component has mounted: 144 | 145 | <<< @/components.ts#tuto5fixed 146 | 147 | ```html 148 | 1.5em
149 | 1.2em 150 | ``` 151 | 152 |
153 | Result 154 | 1.5em
155 | 1.2em 156 |
157 | 158 | Now we get the proper font sizes! 159 | 160 | ## Self-reference 161 | 162 | There is one last thing we would want to do: we want to make sure our Van Element is used properly! 163 | 164 | Currently people can use anything in the slot: plain text, any HTML tags, even script tags 🤔 this might be intended for some components, but here we want to make sure that the only child of our Van Element is: 165 | 166 | - plain text 167 | - not white space 168 | 169 | We can access the reference of the Van Element using `$this` 170 | 171 | <<< @/components.ts#selfReference{2,3} 172 | 173 | ```html 174 | Correct usage
175 |

Wrong usage

176 | ``` 177 | 178 |
179 | Result 180 | Correct usage
181 |

Wrong usage

182 |
183 | 184 | ## That's it! 185 | 186 | You have reached the end of the tutorial! Now you know basically everything there is to know about Van Elements. You can now freely explore the wonders of the Web Component world... or [disable the Shadow DOM](../learn/shadow-options#disable-shadow-dom) if you prefer! 187 | -------------------------------------------------------------------------------- /docs/learn/attributes.md: -------------------------------------------------------------------------------- 1 | # Attributes 2 | 3 | You can retrieve attributes with the provided `attr` method. It takes an attribute name and an optional default value and returns a VanJS `State` object. 4 | 5 | Example: 6 | 7 | <<< @/components.ts#attributes {javascript} 8 | 9 | ```html 10 | 11 | 12 | 13 | 14 | ``` 15 | 16 |
17 | Result 18 | 19 | 20 | 21 |
22 | 23 | ::: tip Note 24 | 25 | This method is a wrapper around `van.state`. Because of this, you will need to use [state derivation](https://vanjs.org/tutorial#state-derived-prop) in places you want to be reactive. 26 | 27 | ```js 28 | define("not-reactive", ({ attr }) => p(`Hello ${attr("name").val}`)); 29 | 30 | define("very-reactive", ({ attr }) => p(() => `Hello ${attr("name").val}`)); 31 | ``` 32 | 33 | ::: 34 | 35 | ## Attribute reactivity 36 | 37 | The `State` obtained from `attr()` is reactive to attribute change. This is useful when nesting Van Elements inside other Van Elements 🤯 38 | 39 | <<< @/components.ts#observed {javascript} 40 | 41 | ```html 42 | 43 | ``` 44 | 45 |
46 | Result 47 | 48 |
49 | 50 | ::: tip Note 51 | 52 | You can use `kebab-case-attributes`. 53 | 54 | ```js 55 | const element = van.tags["some-element"]({ "data-text": "hello" }); 56 | ``` 57 | 58 | Resulting HTML: 59 | 60 | ```html 61 | 62 | ``` 63 | 64 | However you cannot use `camelCaseAttributes` :pleading_face: it is not valid HTML syntax and will be turned into lowercase by the browser. 65 | 66 | ::: 67 | -------------------------------------------------------------------------------- /docs/learn/lifecycle.md: -------------------------------------------------------------------------------- 1 | # Lifecycle 2 | 3 | Sometimes, you want to execute code only when a Van Element has connected to the DOM. The most typical use case is when you try to access `assignedElements` from a slot: 4 | 5 | <<< @/components.ts#mountExample 6 | 7 | ```html 8 |

I am in the slot

9 | ``` 10 | 11 |
12 | Result 13 |

I am in the slot

14 |
15 | 16 | Here, the number of items in the slot is `0` :thinking: that is because slots will only get populated _after_ the Web Component has mounted. 17 | 18 | ## `mount` 19 | 20 | Fortunately, we can define a `mount` callback: 21 | 22 | <<< @/components.ts#mountShowcase 23 | 24 | ```html 25 |

I am in the slot

26 | ``` 27 | 28 |
29 | Result 30 |

I am in the slot

31 |
32 | 33 | ## `dismount` 34 | 35 | The `mount` function can return another callback that triggers when the component is dismounted. 36 | 37 | ```js 38 | mount(() => { 39 | console.log("mounted"); 40 | return () => console.log("dismounted"); 41 | }); 42 | ``` 43 | 44 | This can be useful for unsubscribing to certain events, keeping tracks of mounted elements, etc. 45 | 46 | ::: tip Note 47 | 48 | In most cases, you won't have to use `mount`. However, there are cases where you need it and it will then be very useful! 49 | 50 | ::: 51 | -------------------------------------------------------------------------------- /docs/learn/overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | `Van Element` exposes a single function: `define(...)`. It can take up to 3 arguments: 4 | 5 | - `name` 6 | Custom element tag. 7 | - `element` 8 | VanJS functional component. 9 | - `options` (_optional_) 10 | Extra [Shadow DOM options](./shadow-options). 11 | 12 | The provided VanJS functional component will be called with an object containing the following properties: 13 | 14 | - `attr()` 15 | Method to [retrieve the value of a given attribute](./attributes). 16 | - `mount()` 17 | Lifecycle hook to [register `mount` and `dismount` callbacks](./lifecycle). 18 | - `$this` 19 | Refers to the instance of the created custom element. Useful for accessing properties or binding event listeners. 20 | -------------------------------------------------------------------------------- /docs/learn/shadow-options.md: -------------------------------------------------------------------------------- 1 | # Options 2 | 3 | Internally, Van Elements use `attachShadow` to attach a Shadow root to the element. You can change `attachShadow`'s options with an extra argument to the `define` function. 4 | 5 | ```js 6 | define("my-element", () => p("Closed root, delegating focus 🎉"), { 7 | mode: "closed", 8 | delegatesFocus: true, 9 | }); 10 | ``` 11 | 12 | You can read more about [the Shadow root options on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#parameters). 13 | 14 | ## Disable Shadow DOM 15 | 16 | Instead of the `options` object, you can pass `false` as third argument to disable the Shadow DOM completely. 17 | 18 | ```js 19 | define( 20 | "van-element", 21 | () => p("I don't like isolation 🤗"), 22 | false // Passing false as 3rd argument will disable the Shadow DOM 23 | ); 24 | ``` 25 | 26 | Things that will **stop working**: 27 | 28 | - DOM and style isolation 29 | - slots 30 | 31 | Everything else **will work the exact same**, including: 32 | 33 | - `$this`, `mount`, `attr` 34 | - all VanJS logic 35 | - hydration and reusability 36 | 37 | ## Shadow DOM or not? 38 | 39 | **You can safely disable the Shadow DOM if:** 40 | 41 | - All you want is easy hydration 42 | - Isolation gets in the way 43 | - You don't need slots 44 | 45 | **You _should not_ disable it if:** 46 | 47 | - You are building isolated components (component library, design system) 48 | - You need slots for composition 49 | -------------------------------------------------------------------------------- /docs/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Atmos4/van-element/c9c52cfddbb5820f7f70d6172b74610104b5908a/docs/public/favicon.png -------------------------------------------------------------------------------- /docs/public/logo.color.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /docs/public/logo.dark.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Van Element 7 | 8 | 9 | 33 | 40 | 41 | 42 |

Van Element showcase

43 | 44 |

Theme switch

45 | 46 |

Dynamic attribute injection

47 |

Here is 48 | a Van Element with attributes! 49 |

50 |

(Attributes can have 51 | default values) 52 |

53 |

It can be used inside other Van Elements:

54 | Reactive to attribute change! 🎉 55 | 56 |

Mount and dismount

57 | 58 | 59 |

Modal

60 | 61 | 62 |

Hello there!

63 |

I am a custom modal 🔥

64 |

To close me you can either:

65 | 71 |
72 |

Without Shadow DOM

73 | 74 |

Tabs

75 | 76 |

How is it going? I am just a casual tab 🫡
Clicking other tabs will assign the tabs 77 | slot to another element!

78 |
79 |

The slotted content can be any HTML element or custom element, including Van Elements.

80 |

Let's reuse some elements as an example

81 | 82 | 83 |

I am another custom modal. Same component, but reused!

84 | 85 | 86 |

Here you go!

87 |

It's that easy to do. No duplicated code or hydration boilerplate. Just reusing the exact same component. 88 |

89 |
90 |
91 |
92 |

Pretty cool right? Try to add more tabs!

93 |
94 | 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanjs-element", 3 | "author": "Atmos4", 4 | "license": "MIT", 5 | "version": "2.0.0", 6 | "type": "module", 7 | "description": "Web components with VanJS", 8 | "files": [ 9 | "dist", 10 | "types" 11 | ], 12 | "main": "./dist/van-element.browser.js", 13 | "browser": "./dist/van-element.browser.js", 14 | "module": "./dist/van-element.js", 15 | "types": "./types/van-element.d.ts", 16 | "exports": { 17 | ".": { 18 | "types": "./types/van-element.d.ts", 19 | "import": "./dist/van-element.js", 20 | "require": "./dist/van-element.umd.cjs", 21 | "browser": "./dist/van-element.browser.js" 22 | } 23 | }, 24 | "keywords": [ 25 | "VanJS", 26 | "Web components" 27 | ], 28 | "scripts": { 29 | "dev": "vite", 30 | "build": "bun run scripts/build.ts", 31 | "preview": "vite preview", 32 | "docs:dev": "vitepress dev docs", 33 | "docs:build": "vitepress build docs", 34 | "docs:preview": "vitepress preview docs" 35 | }, 36 | "dependencies": { 37 | "vanjs-core": "^1.5.0" 38 | }, 39 | "devDependencies": { 40 | "@happy-dom/global-registrator": "^15.7.4", 41 | "@types/bun": "latest", 42 | "@types/sinon": "^17.0.3", 43 | "chalk": "^5.3.0", 44 | "gzip-size": "^7.0.0", 45 | "sinon": "^19.0.2", 46 | "terser": "^5.26.0", 47 | "typescript": "^5.2.2", 48 | "vite": "^5.0.0", 49 | "vitepress": "1.3.4" 50 | }, 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/Atmos4/van-element" 54 | }, 55 | "bugs": { 56 | "url": "https://github.com/Atmos4/van-element/issues" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { build } from "vite"; 2 | import chalk from "chalk"; 3 | import { gzipSizeSync } from "gzip-size"; 4 | import { minify } from "terser"; 5 | 6 | async function compress(content: string, file: string) { 7 | const result = await minify(content, { 8 | compress: true, 9 | toplevel: true, 10 | mangle: true, 11 | }); 12 | await Bun.write(file, result.code!); 13 | console.log( 14 | `${file} ` + 15 | chalk.gray( 16 | `${Buffer.from(result.code!).length} B | gzip: ${gzipSizeSync( 17 | result.code! 18 | )} B` 19 | ) 20 | ); 21 | } 22 | 23 | const inputFilePath = "src/van-element.js"; 24 | const outputModuleFilePath = "dist/van-element.js"; 25 | const outputBrowserFilePath = "dist/van-element.browser.js"; 26 | 27 | // vite build 28 | await build(); 29 | 30 | // custom build - optimize output size 31 | const timer = Date.now(); 32 | console.log(chalk.grey(`\nbuilding esm and iife version...`)); 33 | 34 | const bundle = await Bun.file(inputFilePath).text(); 35 | const lines = bundle.split("\n"); 36 | const filteredLines = lines.filter( 37 | (line) => 38 | !line.trim().startsWith("export") && !line.trim().startsWith("import") 39 | ); 40 | const updatedContent = filteredLines.join("\n"); 41 | const finalContent = `${updatedContent}window.vanE={define}`; 42 | 43 | console.log(`${chalk.green("✓")} transformed iife`); 44 | 45 | await compress(finalContent, outputBrowserFilePath); 46 | await compress(bundle, outputModuleFilePath); 47 | console.log(chalk.green(`✓ build in ${Date.now() - timer}ms`)); 48 | -------------------------------------------------------------------------------- /src/showcase.ts: -------------------------------------------------------------------------------- 1 | import van from "vanjs-core"; 2 | import { define } from "./van-element"; 3 | 4 | const { 5 | button, 6 | dialog, 7 | input, 8 | option, 9 | select, 10 | slot, 11 | span, 12 | style, 13 | div, 14 | p, 15 | pre, 16 | } = van.tags; 17 | 18 | define("theme-switch", () => { 19 | const mode = localStorage.getItem("colorScheme"); 20 | const darkMode = van.state( 21 | (mode && mode === "dark") ?? 22 | window.matchMedia?.("(prefers-color-scheme: dark)").matches 23 | ); 24 | van.derive(() => { 25 | const mode = darkMode.val ? "dark" : "light"; 26 | document 27 | .querySelector('meta[name="color-scheme"]') 28 | ?.setAttribute("content", mode); 29 | localStorage.setItem("colorScheme", mode); 30 | }); 31 | return [ 32 | button( 33 | { 34 | style: "font-size: 1.2em", 35 | onclick: () => (darkMode.val = !darkMode.val), 36 | }, 37 | () => (darkMode.val ? "☀️" : "😎") 38 | ), 39 | () => ` Toggle ${darkMode.val ? "light" : "dark"} mode`, 40 | ]; 41 | }); 42 | 43 | define("font-preview", ({ attr }) => 44 | span( 45 | { 46 | style: () => 47 | `font-size: ${Number(attr("size", 12).val) / 8}em; color: ${ 48 | attr("color", "red").val 49 | };`, 50 | }, 51 | slot() 52 | ) 53 | ); 54 | 55 | const animals = ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯"]; 56 | 57 | function getRandomAnimal() { 58 | return animals[Math.floor(Math.random() * animals.length)]; 59 | } 60 | 61 | define("mount-demo", ({ mount, $this }) => { 62 | const animal = getRandomAnimal(); 63 | mount(() => { 64 | const parent = $this.parentElement?.getElementsByTagName("pre")?.[0]; 65 | parent?.append(div(`${animal} mounted`)); 66 | return () => { 67 | parent?.append(div(`${animal} dismounted`)); 68 | }; 69 | }); 70 | return div(animal, " ", button({ onclick: () => $this.remove() }, "💀")); 71 | }); 72 | 73 | define("mount-showcase", ({ $this, mount }) => { 74 | const console = pre({ slot: "console" }); 75 | mount(() => { 76 | van.add($this, console); 77 | }); 78 | return div( 79 | { 80 | style: 81 | "display:grid;grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));gap:1rem", 82 | }, 83 | style("button{font:inherit}"), 84 | div( 85 | button( 86 | { onclick: () => $this.append(van.tags["mount-demo"]()) }, 87 | "Add animal" 88 | ), 89 | slot() 90 | ), 91 | div( 92 | "Console: ", 93 | button({ onclick: () => (console.textContent = "") }, "Clear"), 94 | slot({ name: "console" }) 95 | ) 96 | ); 97 | }); 98 | 99 | define("demo-component", () => { 100 | const size = van.state(10), 101 | color = van.state("green"); 102 | return span( 103 | "Size: ", 104 | input({ 105 | type: "range", 106 | min: 5, 107 | max: 20, 108 | value: size, 109 | oninput: (e) => (size.val = e.target.value), 110 | }), 111 | " Color: ", 112 | select( 113 | { 114 | oninput: (e) => (color.val = e.target.value), 115 | value: color, 116 | style: "font:inherit", 117 | }, 118 | ["green", "black", "blue", "red", "brown"].map((c) => 119 | option({ value: c }, c) 120 | ) 121 | ), 122 | div(van.tags["font-preview"]({ size: size, color: color }, slot())) 123 | ); 124 | }); 125 | 126 | define("custom-modal", () => { 127 | const modal = dialog( 128 | { 129 | style: "padding:0;border-radius: 10px", 130 | onclick: (e) => e.target === modal && modal.close(), 131 | }, 132 | div( 133 | { style: "padding:1rem 2rem;position:relative" }, 134 | button( 135 | { 136 | onclick: () => modal.close(), 137 | style: "position:absolute;top:10px;right:10px;line-height:1.2rem", 138 | }, 139 | "❌" 140 | ), 141 | slot() 142 | ) 143 | ); 144 | 145 | return [ 146 | // Some nice styles for the dialog 147 | style(` 148 | dialog[open] { 149 | opacity: 1; 150 | transform: scale(1); 151 | } 152 | dialog { 153 | opacity: 0; 154 | transform: scale(0.5); 155 | transition: all 0.2s allow-discrete; 156 | } 157 | @starting-style { 158 | dialog[open] { 159 | opacity: 0; 160 | transform: scale(0.5); 161 | } 162 | } 163 | dialog::backdrop { 164 | background-color: rgb(0 0 0 / 0); 165 | transition: all 0.2s allow-discrete; 166 | } 167 | dialog[open]::backdrop { 168 | background-color: rgb(0 0 0 / 0.25); 169 | } 170 | @starting-style { 171 | dialog[open]::backdrop { 172 | background-color: rgb(0 0 0 / 0); 173 | } 174 | } 175 | `), 176 | slot({ name: "open-button", onclick: () => modal.showModal() }), 177 | modal, 178 | ]; 179 | }); 180 | 181 | define("tab-panel", ({ mount, $this }) => { 182 | const tabButtons = div({ 183 | style: "display:flex;gap:0.2rem", 184 | }); 185 | const selectedTab = van.state(""); 186 | const tabContent = slot({ name: "tab" }, p("No tab selected")); 187 | mount(() => 188 | Array.from($this.children).forEach((p, i) => { 189 | const tabTitle = p.getAttribute("data-tab") || `Tab ${i + 1}`; 190 | if (p.getAttribute("slot")) { 191 | selectedTab.val = tabTitle; 192 | } 193 | van.add( 194 | tabButtons, 195 | button( 196 | { 197 | onclick: () => { 198 | tabContent.assignedElements()[0]?.removeAttribute("slot"); 199 | p.setAttribute("slot", "tab"); 200 | selectedTab.val = tabTitle; 201 | }, 202 | style: () => 203 | `border-bottom:2px solid${ 204 | selectedTab.val == tabTitle ? "" : " transparent" 205 | }`, 206 | }, 207 | tabTitle 208 | ) 209 | ); 210 | }) 211 | ); 212 | return [ 213 | style( 214 | "button{font:inherit;padding: 0.5rem;border: none}#tab-area{min-height:200px}" 215 | ), 216 | div(tabButtons, div({ id: "tab-area" }, tabContent)), 217 | ]; 218 | }); 219 | 220 | define("light-dom", () => p("I am in the light!")); 221 | -------------------------------------------------------------------------------- /src/van-element.d.ts: -------------------------------------------------------------------------------- 1 | export * from "../types/van-element"; 2 | -------------------------------------------------------------------------------- /src/van-element.js: -------------------------------------------------------------------------------- 1 | import van from "vanjs-core"; 2 | 3 | // Short prop names because class props are not minified. 4 | function define(name, element, options = { mode: "open" }) { 5 | window.customElements.define( 6 | name, 7 | class extends HTMLElement { 8 | constructor() { 9 | super(); 10 | // Attributes 11 | this.a = []; 12 | } 13 | setAttribute(name, value) { 14 | super.setAttribute(name, value); 15 | this.a[name] && (this.a[name].val = value); 16 | } 17 | connectedCallback() { 18 | let mount; 19 | van.add( 20 | options ? this.attachShadow(options) : this, 21 | element({ 22 | attr: (i, v) => 23 | (this.a[i] ??= van.state(this.getAttribute(i) ?? v)), 24 | mount: (newMount) => { 25 | let currentMount = mount; 26 | mount = () => { 27 | let currentDismount = currentMount?.(); 28 | let newDismount = newMount(); 29 | return () => { 30 | currentDismount?.(); 31 | newDismount?.(); 32 | }; 33 | }; 34 | }, 35 | $this: this, 36 | }) 37 | ); 38 | // Dismount 39 | this.d = mount?.(); 40 | } 41 | disconnectedCallback() { 42 | this.d?.(); 43 | } 44 | } 45 | ); 46 | } 47 | 48 | export { define }; 49 | -------------------------------------------------------------------------------- /tests/internals.test.ts: -------------------------------------------------------------------------------- 1 | import van from "vanjs-core"; 2 | import { beforeEach, describe, expect, it, jest, mock } from "bun:test"; 3 | import { define } from "../src/van-element"; 4 | import { useFakeTimers } from "sinon"; 5 | 6 | const { button, div, slot } = van.tags; 7 | 8 | const mountFn = mock(); 9 | const unmountFn = mock(); 10 | const secondMount = mock(); 11 | const secondUnmount = mock(); 12 | const clickFn = mock(); 13 | const fakeTimer = useFakeTimers(); 14 | 15 | define("internals-test", ({ attr, $this, mount }) => { 16 | const attribute = attr("attribute", "default"); 17 | 18 | const count = van.state(0); 19 | 20 | mount(() => { 21 | mountFn(); 22 | return unmountFn; 23 | }); 24 | 25 | mount(() => { 26 | secondMount(); 27 | return secondUnmount; 28 | }); 29 | 30 | const onClick = () => { 31 | clickFn(); 32 | count.val++; 33 | $this.dispatchEvent(new CustomEvent("count", { detail: count.val })); 34 | }; 35 | return [ 36 | div("Attribute: ", attribute), 37 | button({ onclick: () => onClick() }, "Count: ", count), 38 | slot(), 39 | ]; 40 | }); 41 | 42 | describe("check that a Van Element", async () => { 43 | beforeEach(() => { 44 | fakeTimer.reset(); 45 | jest.clearAllMocks(); 46 | }); 47 | 48 | it("has VanJS behavior", async () => { 49 | mountComponent(); 50 | queryInShadow("button")?.click(); 51 | expect(clickFn).toHaveBeenCalled(); 52 | await flushUpdates(); 53 | expect(queryInShadow("button")?.textContent).toContain("1"); 54 | }); 55 | 56 | it("has attribute reactivity", async () => { 57 | mountComponent("Jack"); 58 | expect(queryInShadow("div")?.textContent).toContain("Jack"); 59 | getComponent()!.setAttribute("attribute", "John"); 60 | await flushUpdates(); 61 | expect(queryInShadow("div")?.textContent).toContain("John"); 62 | }); 63 | 64 | it("mounts and unmounts properly", () => { 65 | expect(mountFn).not.toHaveBeenCalled(); 66 | mountComponent(); 67 | expect(mountFn).toHaveBeenCalled(); 68 | getComponent()!.remove(); 69 | expect(unmountFn).toHaveBeenCalled(); 70 | }); 71 | 72 | it("propagates events out", () => { 73 | const spyClick = mock(); 74 | mountComponent(); 75 | getComponent()!.addEventListener("count", spyClick); 76 | queryInShadow("button")?.click(); 77 | expect(spyClick).toHaveBeenCalled(); 78 | }); 79 | 80 | it("has a main slot", () => { 81 | mountComponent("Bob", "Hi mom"); 82 | const slotted = queryInShadow("slot")?.assignedNodes(); 83 | expect(slotted?.[0].textContent).toContain("Hi mom"); 84 | }); 85 | 86 | it("multiple mount callbacks", () => { 87 | mountComponent(); 88 | expect(mountFn).toHaveBeenCalled(); 89 | expect(secondMount).toHaveBeenCalled(); 90 | getComponent()!.remove(); 91 | expect(unmountFn).toHaveBeenCalled(); 92 | expect(secondUnmount).toHaveBeenCalled(); 93 | }); 94 | }); 95 | 96 | // helper functions 97 | function getComponent() { 98 | return document.body.querySelector("internals-test"); 99 | } 100 | 101 | function queryInShadow( 102 | selector: K 103 | ): HTMLElementTagNameMap[K] | null | undefined { 104 | return getComponent()?.shadowRoot?.querySelector(selector); 105 | } 106 | 107 | function mountComponent(name = "Peter", children = "") { 108 | document.body.innerHTML = `${children}`; 109 | } 110 | 111 | async function flushUpdates() { 112 | await fakeTimer.runAllAsync(); 113 | } 114 | -------------------------------------------------------------------------------- /tests/light-dom.test.ts: -------------------------------------------------------------------------------- 1 | import van from "vanjs-core"; 2 | import { define } from "../src/van-element"; 3 | import { describe, expect, it } from "vitest"; 4 | 5 | const { div } = van.tags; 6 | 7 | define("light-dom", () => div({ class: "light" }, "Hello"), false); 8 | define("shadow-dom", () => div({ class: "shadow" }, "World")); 9 | 10 | describe("Shadow DOM options", () => { 11 | it("should expose light Van Element", () => { 12 | document.body.innerHTML = ``; 13 | expect(document.querySelector(".light")).toBeTruthy(); 14 | }); 15 | 16 | it("should encapsulate shadow Van Element", () => { 17 | document.body.innerHTML = ``; 18 | expect( 19 | document.querySelector("shadow-dom")?.shadowRoot?.querySelector(".shadow") 20 | ).toBeTruthy(); 21 | expect(document.querySelector(".shadow")).toBeFalsy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | 3 | GlobalRegistrator.register(); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /types/van-element.d.ts: -------------------------------------------------------------------------------- 1 | import { State, ChildDom } from "vanjs-core"; 2 | 3 | export type ElementProps = { 4 | /** Get the value of an attribute */ 5 | attr: (name: string, defaultValue?: string | number) => State; 6 | /** Registers a callback that is called when the element connects to the DOM */ 7 | mount: ( 8 | /** Callback when the element connects to the DOM 9 | * @returns An optional dismount callback 10 | */ 11 | mount: () => (() => void) | void 12 | ) => void; 13 | /** Instance of the custom element */ 14 | $this: HTMLElement; 15 | }; 16 | 17 | /** 18 | * Defines a VanJS custom element. 19 | */ 20 | export declare const define: ( 21 | /** Name of the custom element */ 22 | name: string, 23 | /** VanJS functional component */ 24 | element: ( 25 | /** Attributes of the custom element */ 26 | attributes: ElementProps 27 | ) => ChildDom, 28 | options?: ShadowRootInit | false 29 | ) => void; 30 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | build: { 5 | lib: { 6 | entry: "src/van-element.js", 7 | name: "vanE", 8 | formats: ["umd"], 9 | fileName: "van-element", 10 | }, 11 | rollupOptions: { 12 | external: ["vanjs-core"], 13 | output: { 14 | globals: { 15 | "vanjs-core": "van", 16 | }, 17 | }, 18 | }, 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------