├── .github └── workflows │ └── publish-docs.yml ├── .gitignore ├── LICENSE ├── README.md ├── guides ├── guides.json └── slots.md ├── index.js ├── jsdoc.config.json ├── package-lock.json ├── package.json └── readme_files ├── docstyle.css └── svawc logo.png /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: GitHub pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | 15 | - name: Install node 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 'lts/*' 19 | cache: 'npm' 20 | 21 | - name: Install deps 22 | run: npm ci 23 | 24 | - name: Build docs 25 | run: npm run doc 26 | 27 | - name: Deploy 28 | uses: peaceiris/actions-gh-pages@v3 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | publish_dir: ./doc 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | doc 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Immers Space 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 | ![logo - cartoon seagul with a wide open beak and the letters S V A W C](./readme_files/svawc%20logo.png) 2 | 3 | # SVAWC 4 | 5 | **Sv**elte **A**-Frame **W**eb **C**omponents 6 | 7 | SVAWC brings modern reactive development and HTML templating to A-Frame component development without compromising on speed, usability, or bundle size. 8 | 9 | ## How it works 10 | 11 | 1. Write reactive template code using Svelte 12 | 1. Svelte compiles that down to efficient `createElement`, `setAttibute`, et c. calls (no virtual DOM or unecessary entity recreation) 13 | 2. SVAWC packages it into Web Components for distribution 14 | 3. Link the packaged script and then use the Web Component in any A-Frame scene, works with bundled apps and vanilla JS & HTML 15 | 16 | ## What it looks like 17 | 18 | **Svelte reactive template source:** 19 | 20 | ```Svelte 21 | 22 | 33 | 34 | 41 | 48 | 49 | {#each limbs as side (side)} 50 | 58 | {/each} 59 | 60 | {#each limbs as side (side)} 61 | 69 | {/each} 70 | ``` 71 | 72 | The above is just standard Svelte code. 73 | [Check out their guide](https://svelte.dev/tutorial/basics) if you're not already familiar. 74 | 75 | **SVAWC Wrapper:** 76 | 77 | ```js 78 | import { registerWebComponent } from 'svawc' 79 | import APerson from "./APerson.svelte" 80 | registerWebComponent({Component: APerson, tagname: "a-person", props: ["skinColor", "shirtColor", "pantsColor"] }) 81 | ``` 82 | 83 | 84 | Usage in A-Frame Scene: 85 | 86 | ```html 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | ``` 99 | 100 | [Try it out](https://momentous-jelly-secure.glitch.me/) 101 | 102 | ## Why it's useful 103 | 104 | I love A-Frame, but the recurring pain points for me in large apps are handling complex reactive state 105 | and making nested entity structures re-usable. 106 | 107 | Solutions for the reactive state generally involve meta-components 108 | like `event-set` or the creation of one-off 'components' that just handle business logic. 109 | These tend to spread your logic around and make a large codebase harder to maintain. 110 | For re-usable structures, you're either stuck with HTML templates, which are awkward to use, bloat your index.html, 111 | and again serve to keep your structure far from your logic, or you've got to write tons of tedious 112 | `createElement` and `setAttribute` calls. 113 | 114 | SVAWC lets you write the organized, concise code we're accustomed to from modern 115 | reactive frameworks and integrate it seamlessly in any A-Frame project. SVAWC is 116 | the A-Frame answer to React Three Fiber, which is a lovely and powerful framework, 117 | but never feels quite right to me due the lack of ECS. 118 | 119 | ## API documentation 120 | 121 | View the full API documentation at 122 | [https://immers-space.github.io/svawc](https://immers-space.github.io/svawc) 123 | 124 | ## Get Started 125 | 126 | The [svawc-template repo](https://github.com/immers-space/svawc-template/) has everything you need to start building and publishing SVAWCs. 127 | [Click here to create a copy of it](https://github.com/immers-space/svawc-template/generate). 128 | 129 | ## Feature status 130 | 131 | This library is fully functional, but some of the features still need some polish 132 | 133 |
134 |
🙂 Svelte props as HTML Attributes
135 |
136 | Svelte props become attributes on the custom element, converting camelCase to dash-case 137 | automatically. For now, the props must be explicitly listed in the `props` option, but 138 | I'd like to be able to infer them automatically in the future. 139 |
140 |
😀 Light DOM
141 |
142 | All component output is rendered to the light DOM as children of the custom element. 143 | Shadow DOM is not available as the boundary breaks A-Frame's scene graph logic, 144 | and the benefits of Shadow DOM are primarily CSS encapsulation which isn't relevant here. 145 |
146 |
😀 Slots
147 |
148 | Full slot functionality is available including default and named slots with the small caveat 149 | that the slot content must be wrapped in a template tag. 150 | See slots tutorial for details. 151 |
152 |
😦 Dependency Injection
153 |
154 | Not available yet, but I'd like to have it work where dependencies on A-Frame components can be 155 | re-used from the consuming app if already installed or injected via CDN if not so that we don't 156 | have bundle extra code in our SVAWCs nor worry about duplicate component registration. 157 |
158 |
😦 Loading order gotcha
159 |
160 | SVAWC scripts mustn't be loaded as type="module", async, or defer as this puts it in 161 | conflict with the A-Frame loading order. Instead, load it as a normal script in your HEAD 162 | and it will work. 163 | (This is now fixed in A-Frame source, and will be available in the next release after 1.4.1 or you can use the current master build) 164 |
165 |
166 | 167 | Key: 😀 complete, 🙂 fully functional but could be improved, 😦 missing or has issues 168 | 169 | ## Acknowledgements 170 | 171 | Big thanks to @dmarcos for undertaking the massive task of porting A-Frame over to 172 | native Custom Elements for v1.4.0; this would not be possible otherwise. 173 | 174 | Code adapted from [svelte-tag](https://github.com/crisward/svelte-tag) by Chris Ward. 175 | 176 | Logo is CC-BY-NC-SA, adapted from a photo by Leonard J Matthews. 177 | -------------------------------------------------------------------------------- /guides/guides.json: -------------------------------------------------------------------------------- 1 | { 2 | "slots": { 3 | "title": "Using Slots" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /guides/slots.md: -------------------------------------------------------------------------------- 1 | Slots are an important feature for customization and composability. 2 | You can specify locations in your SVAWC's entity tree where 3 | the users of your component can provide their own entities. 4 | For example, a humanoid character SVAWC might have a slot placed inside their 5 | hand entity so that different items can be easily inserted where they'll 6 | be parented to the hand and move along with it. 7 | 8 | ### Defining SVAWC Slots 9 | 10 | On the SVAWC development side, slots work exactly as they do in 11 | Svelte components ([see guide](https://svelte.dev/tutorial/slots)). 12 | All you do is place `` a element in your component's HTML template to 13 | mark the place where content can be inserted. 14 | You can also place default content inside the slot tags that will be used 15 | only when custom content is not provided by the SVAWC user. 16 | 17 | This `a-cage` SVAWC creates a wireframe box around whatever content 18 | is placed in its slot: 19 | 20 | ```svelte 21 | 22 | 23 | 24 | 25 | ``` 26 | 27 | ### Using SVAWC Slots 28 | 29 | On the SVAWC usage side, there is one difference from how slots are used 30 | in Svelte or HTML WebComponents. 31 | You still add entities as children of the SVAWC to populate the slots, 32 | but you must wrap it in a `` tag. 33 | This is to prevent the A-Frame entities from initializing until after 34 | they are moved to their slotted locations. 35 | 36 | This scene places a sphere within the `a-cage` SVAWC 37 | 38 | ```html 39 | 40 | 43 | 44 | ``` 45 | 46 | ### Multiple slots 47 | 48 | Just as in Svelte or WebComponents, you can have multiple slots 49 | that are differentiate with the `name` attribute, and you can 50 | have one default slot that doesn't have a name. Using 51 | these slots also works just like standard slots, using 52 | the `slot` attribute on the to specify the name. 53 | 54 | This `a-camera-rig` SVAWC has named slots to attach 55 | entities to the camera and each tracked controller 56 | as well as a default slot to place content relative to the entire rig 57 | 58 | ```svelte 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ``` 71 | 72 | ```html 73 | 74 | 77 | 80 | 83 | 86 | 87 | ``` 88 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Adapted from svelte-tag by Chris Ward 3 | */ 4 | 5 | // witchcraft from svelte issue - https://github.com/sveltejs/svelte/issues/2588 6 | import { detach, insert, noop } from 'svelte/internal' 7 | function createSlots (slots) { 8 | const svelteSlots = {} 9 | for (const slotName in slots) { 10 | svelteSlots[slotName] = [createSlotFn(slots[slotName])] 11 | } 12 | function createSlotFn (element) { 13 | return function () { 14 | return { 15 | c: noop, 16 | m: function mount (target, anchor) { 17 | insert(target, element.cloneNode(true), anchor) 18 | }, 19 | d: function destroy (detaching) { 20 | if (detaching && element.innerHTML) { 21 | detach(element) 22 | } 23 | }, 24 | l: noop 25 | } 26 | } 27 | } 28 | return svelteSlots 29 | } 30 | /** 31 | * Convert a Svelte component into a Web Component for A-Frame. 32 | * 33 | * Provides the wrapperElement context to the Svelte component with a reference to the containing 34 | * custom element instance. 35 | * @param {object} opts 36 | * @param {function} opts.Component - the Svelte component constructor 37 | * @param {string} opts.tagname - the element tag for the Web Component, must contain a '-' 38 | * @param {string[]} [opts.props] - the prop names from the Svelte copmonent which will be settable via HTML attributes (auto-converted between camelCase an dash-case) 39 | * @param {HTMLElement} [opts.baseClass] - base class that Web Component element will inherit from, default's to AEntity 40 | * @param {boolean} [opts.noWraper] - EXPERIMENTAL: render the Svelte component output as siblings to the Web Component element instead of as children 41 | * @example Basic usage 42 | * // creates and registers the custom element from the APerson.svelte component 43 | * import { registerWebComponent } from 'svawc' 44 | * import APerson from "./APerson.svelte" 45 | * registerWebComponent({ component: APerson, tagname: "a-person", attributes: ["skin", "shirt", "pants"] }) 46 | * @example Using context to modify the containing element from inside the Svelte component 47 | * import { getContext } from "svelte"; 48 | * getContext('wrapperElement').setAttribute('shadow', '') 49 | */ 50 | export function registerWebComponent (opts) { 51 | const BaseClass = opts.baseClass ?? window.AFRAME.AEntity 52 | opts.props ??= [] 53 | // setup camel/dash case conversions 54 | const attributes = opts.props.map(prop => dashify(prop)) 55 | const toDash = Object.fromEntries(opts.props.map((prop, i) => [prop, attributes[i]])) 56 | const toCamel = Object.fromEntries(opts.props.map((prop, i) => [attributes[i], prop])) 57 | class Wrapper extends BaseClass { 58 | constructor () { 59 | super() 60 | this.addEventListener('nodeready', () => this.init()) 61 | } 62 | 63 | static get observedAttributes () { 64 | return (attributes).concat(BaseClass.observedAttributes || []) 65 | } 66 | 67 | // use init on nodeready instead of connectedCallback to avoid 68 | // issues with multiple calls due to A-Frame's initialization delay 69 | init () { 70 | const props = {} 71 | props.$$scope = {} 72 | opts.props.forEach(prop => { 73 | if (this.hasAttribute(toDash[prop])) { 74 | props[prop] = this.getAttribute(toDash[prop]) 75 | } 76 | }) 77 | props.$$scope = {} 78 | const slots = this.getSlots() 79 | props.$$slots = createSlots(slots) 80 | const context = new Map([['wrapperElement', opts.noWrapper ? null : this]]) 81 | this.elem = new opts.Component({ target: opts.noWrapper ? this.parentElement : this, props, context }) 82 | } 83 | 84 | disconnectedCallback () { 85 | super.disconnectedCallback?.() 86 | if (this.observe) { 87 | this.observer.disconnect() 88 | } 89 | try { this.elem.$destroy() } catch (err) {} // detroy svelte element when removed from dom 90 | } 91 | 92 | unwrap (from) { 93 | if (!from.content) { 94 | console.warn('svawc: entities in slots should be wrapped in a template element') 95 | const frag = document.createDocumentFragment() 96 | frag.appendChild(from) 97 | return frag 98 | } 99 | from.remove() 100 | return from.content 101 | } 102 | 103 | getSlots () { 104 | const namedSlots = this.querySelectorAll('[slot]') 105 | const slots = {} 106 | namedSlots.forEach(n => { 107 | slots[n.slot] = this.unwrap(n) 108 | }) 109 | const defaultSlot = this.firstElementChild 110 | if (defaultSlot) { 111 | slots.default = this.unwrap(defaultSlot) 112 | } else if (this.textContent.trim().length) { 113 | // if the only child is text, wrap in fragment and use as default slot 114 | slots.default = document.createDocumentFragment() 115 | slots.default.textContent = this.textContent.trim() 116 | } 117 | this.innerHTML = '' 118 | return slots 119 | } 120 | 121 | attributeChangedCallback (name, oldValue, newValue) { 122 | if (!attributes.includes(name)) { 123 | // passthrough for inherited attrs 124 | return super.attributeChangedCallback?.() 125 | } 126 | if (this.elem && newValue !== oldValue) { 127 | this.elem.$set({ [toCamel[name]]: newValue }) 128 | } 129 | } 130 | } 131 | window.customElements.define(opts.tagname, Wrapper) 132 | return Wrapper 133 | } 134 | 135 | /*! 136 | * dashify 137 | * 138 | * Copyright (c) 2015-2017, Jon Schlinkert. 139 | * Released under the MIT License. 140 | */ 141 | 142 | function dashify (str, options) { 143 | if (typeof str !== 'string') throw new TypeError('expected a string') 144 | return str.trim() 145 | .replace(/([a-z])([A-Z])/g, '$1-$2') 146 | .replace(/\W/g, m => /[À-ž]/.test(m) ? m : '-') 147 | .replace(/^-+|-+$/g, '') 148 | .replace(/-{2,}/g, m => options && options.condense ? '-' : m) 149 | .toLowerCase() 150 | }; 151 | -------------------------------------------------------------------------------- /jsdoc.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": ["category", "optional"] 4 | }, 5 | 6 | "plugins": [ 7 | "plugins/markdown", 8 | "node_modules/better-docs/category", 9 | "node_modules/better-docs/typedef-import" 10 | ], 11 | "opts": { 12 | "encoding": "utf8", 13 | "destination": "doc/", 14 | "readme": "README.md", 15 | "recurse": true, 16 | "verbose": true, 17 | "tutorials": "guides", 18 | "template": "node_modules/better-docs" 19 | }, 20 | "templates": { 21 | "cleverLinks": false, 22 | "monospaceLinks": false, 23 | "search": false, 24 | "default": { 25 | }, 26 | "better-docs": { 27 | "name": "SVAWC Documentation", 28 | "title": "SVAWC Documentation", 29 | "css": "readme_files/docstyle.css", 30 | "trackingCode": "", 31 | "hideGenerator": false, 32 | "navLinks": [ 33 | { 34 | "label": "Github", 35 | "href": "https://github.com/immers-space/svawc" 36 | } 37 | ] 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svawc", 3 | "version": "0.0.2", 4 | "description": "Modern reactive development and HTML templating to A-Frame component development without compromising on speed, usability, or bundle size", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "doc": "jsdoc -c jsdoc.config.json -d doc -r index.js -R README.md && cp -R readme_files doc/" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/immers-space/svawc.git" 13 | }, 14 | "keywords": [ 15 | "aframe", 16 | "svelte", 17 | "web", 18 | "components" 19 | ], 20 | "author": "William Murphy", 21 | "license": "MIT", 22 | "peerDependencies": { 23 | "aframe": "^1.4.1", 24 | "svelte": "^3.55.1" 25 | }, 26 | "devDependencies": { 27 | "better-docs": "^2.7.2", 28 | "jsdoc": "^4.0.0", 29 | "standard": "^17.0.0", 30 | "taffydb": "^2.7.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /readme_files/docstyle.css: -------------------------------------------------------------------------------- 1 | .tag:not(body) { 2 | text-transform: none; 3 | color: #c66; 4 | background: none; 5 | } 6 | -------------------------------------------------------------------------------- /readme_files/svawc logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immers-space/svawc/0822c8a836b4eae4f518083166e5685ee4dbe9c2/readme_files/svawc logo.png --------------------------------------------------------------------------------